From 4453d50f748d261eac5a86fa09f773a4526d1969 Mon Sep 17 00:00:00 2001 From: Qasim Date: Sun, 1 Feb 2026 15:07:01 -0500 Subject: [PATCH 1/2] feat(email): add GPG/PGP email signing and verification Add support for signing outgoing emails with GPG keys and verifying signatures on incoming emails using the PGP/MIME standard (RFC 3156). Features: - Sign emails with --sign flag using default or specific GPG key - Verify signatures with --verify flag on email read - Auto-fetch missing public keys from key servers - Configure default key and auto-sign via nylas config - List available GPG keys with --list-gpg-keys New files: - internal/adapters/gpg/ - GPG service for signing/verification - internal/adapters/mime/ - RFC 3156 PGP/MIME message builder - internal/cli/email/send_gpg.go - GPG signing flow - internal/cli/email/read_verify.go - Signature verification - docs/commands/email-signing.md - User documentation - docs/commands/explain-gpg.md - GPG concepts guide Security: - Input validation for GPG key IDs (SEC-001, SEC-002) - Cryptographically random MIME boundaries (SEC-003) - Comprehensive test coverage including injection tests --- CLAUDE.md | 5 +- docs/COMMANDS.md | 4 +- docs/commands/email-signing.md | 405 ++++++++ docs/commands/explain-gpg.md | 969 ++++++++++++++++++ internal/adapters/gpg/service.go | 518 ++++++++++ internal/adapters/gpg/service_test.go | 543 ++++++++++ internal/adapters/gpg/types.go | 33 + internal/adapters/mime/builder.go | 426 ++++++++ internal/adapters/mime/builder_test.go | 376 +++++++ internal/adapters/nylas/demo_messages.go | 9 + internal/adapters/nylas/messages.go | 21 +- internal/adapters/nylas/messages_send.go | 56 + .../adapters/nylas/messages_send_raw_test.go | 359 +++++++ internal/adapters/nylas/mock_client.go | 1 + internal/adapters/nylas/mock_messages.go | 13 + internal/cli/config/config.go | 6 + internal/cli/config/get.go | 27 +- internal/cli/config/get_test.go | 12 +- internal/cli/config/gpg_test.go | 189 ++++ internal/cli/config/set.go | 8 +- internal/cli/config/set_test.go | 12 +- internal/cli/email/helpers.go | 24 +- internal/cli/email/read.go | 19 +- internal/cli/email/read_verify.go | 341 ++++++ internal/cli/email/read_verify_test.go | 262 +++++ internal/cli/email/send.go | 97 +- internal/cli/email/send_gpg.go | 204 ++++ internal/cli/email/send_gpg_test.go | 179 ++++ internal/cli/email/send_test.go | 453 ++++++++ internal/cli/integration/email_gpg_test.go | 363 +++++++ internal/domain/config.go | 9 + internal/domain/email.go | 4 + internal/ports/messages.go | 3 + 33 files changed, 5900 insertions(+), 50 deletions(-) create mode 100644 docs/commands/email-signing.md create mode 100644 docs/commands/explain-gpg.md create mode 100644 internal/adapters/gpg/service.go create mode 100644 internal/adapters/gpg/service_test.go create mode 100644 internal/adapters/gpg/types.go create mode 100644 internal/adapters/mime/builder.go create mode 100644 internal/adapters/mime/builder_test.go create mode 100644 internal/adapters/nylas/messages_send_raw_test.go create mode 100644 internal/cli/config/gpg_test.go create mode 100644 internal/cli/email/read_verify.go create mode 100644 internal/cli/email/read_verify_test.go create mode 100644 internal/cli/email/send_gpg.go create mode 100644 internal/cli/email/send_gpg_test.go create mode 100644 internal/cli/email/send_test.go create mode 100644 internal/cli/integration/email_gpg_test.go diff --git a/CLAUDE.md b/CLAUDE.md index c064850..96e3125 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ make ci # Runs: fmt → vet → lint → test-unit → test-race → secu - **CLI Framework**: Cobra - **API**: Nylas v3 ONLY (never use v1/v2) - **Timezone Support**: Offline utilities + calendar integration ✅ +- **Email Signing**: GPG/PGP email signing (RFC 3156 PGP/MIME) ✅ - **Credential Storage**: System keyring (see below) - **Web UI**: Air - browser-based interface (localhost:7365) @@ -123,10 +124,12 @@ Credentials from `nylas auth config` are stored in the system keyring under serv **Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/` -**New packages (2024):** +**New packages (2024-2026):** - `internal/ports/output.go` - OutputWriter interface for pluggable formatting - `internal/adapters/output/` - Table, JSON, YAML, Quiet output adapters - `internal/httputil/` - HTTP response helpers (WriteJSON, LimitedBody, DecodeJSON) +- `internal/adapters/gpg/` - GPG/PGP email signing service (2026) +- `internal/adapters/mime/` - RFC 3156 PGP/MIME message builder (2026) **Full inventory:** `docs/ARCHITECTURE.md` diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index da2d6b2..0d49731 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -115,6 +115,8 @@ nylas email read # Read email nylas email read --raw # Show raw body without HTML nylas email read --mime # Show raw RFC822/MIME format nylas email send --to EMAIL --subject SUBJECT --body BODY # Send email +nylas email send --to EMAIL --subject SUBJECT --body BODY --sign # Send GPG-signed email +nylas email send --list-gpg-keys # List available GPG signing keys nylas email search --query "QUERY" # Search emails nylas email delete # Delete email nylas email mark read # Mark as read @@ -136,7 +138,7 @@ nylas email ai analyze --provider claude # Use specific AI provider nylas email smart-compose --prompt "..." # AI-powered email generation ``` -**Details:** `docs/commands/email.md`, `docs/commands/ai.md` +**Details:** `docs/commands/email.md`, `docs/commands/email-signing.md`, `docs/commands/ai.md` --- diff --git a/docs/commands/email-signing.md b/docs/commands/email-signing.md new file mode 100644 index 0000000..517aaae --- /dev/null +++ b/docs/commands/email-signing.md @@ -0,0 +1,405 @@ +# GPG Email Signing + +Sign outgoing emails with your GPG/PGP key for cryptographic authentication. + +--- + +## Overview + +The Nylas CLI supports signing emails with GPG (GNU Privacy Guard) using the OpenPGP standard (RFC 3156). Signed emails allow recipients to verify: +- The email was sent by you (authentication) +- The email content hasn't been tampered with (integrity) + +**Note:** This feature signs emails but does not encrypt them. Email content remains readable. + +--- + +## Prerequisites + +### 1. Install GPG + +**Linux (Debian/Ubuntu):** +```bash +sudo apt install gnupg +``` + +**macOS:** +```bash +brew install gnupg +``` + +**Windows:** +Download from: https://gnupg.org/download/ + +### 2. Generate a GPG Key + +If you don't have a GPG key: + +```bash +gpg --gen-key +``` + +Follow the prompts to create your key. Use the same email address you send emails from. + +### 3. Configure Git (Optional but Recommended) + +Set your default signing key: + +```bash +gpg --list-secret-keys --keyid-format=long +# Copy your key ID (e.g., 601FEE9B1D60185F) + +git config --global user.signingkey 601FEE9B1D60185F +``` + +### 4. Configure Nylas CLI (Optional) + +Set default GPG key and auto-sign preferences: + +```bash +# Set default GPG signing key +nylas config set gpg.default_key 601FEE9B1D60185F + +# Enable auto-sign for all outgoing emails +nylas config set gpg.auto_sign true + +# View GPG configuration +nylas config get gpg.default_key +nylas config get gpg.auto_sign +``` + +**Key Selection Priority:** +1. `--gpg-key ` flag (highest priority) +2. `gpg.default_key` from Nylas config +3. From email address (auto-detected) +4. `user.signingkey` from git config (lowest priority) + +--- + +## Usage + +### List Available GPG Keys + +See all GPG keys available for signing: + +```bash +nylas email send --list-gpg-keys +``` + +**Output:** +``` +Available GPG signing keys (1): + +1. Key ID: 601FEE9B1D60185F + Fingerprint: E4D944EDAD2E329591A8854B07DD577603B27996 + UID: John Doe + Expires: 2031-01-31 + +Default signing keys: + From Nylas config: 601FEE9B1D60185F + From git config: 601FEE9B1D60185F +``` + +### Sign with Default Key + +Sign an email using your configured default key (Nylas config or git config): + +```bash +nylas email send \ + --to recipient@example.com \ + --subject "Signed Email" \ + --body "This email is cryptographically signed." \ + --sign +``` + +### Sign with Specific Key + +Sign with a specific GPG key ID: + +```bash +nylas email send \ + --to recipient@example.com \ + --subject "Secure Message" \ + --body "Signed with my work key." \ + --sign \ + --gpg-key 601FEE9B1D60185F +``` + +### Sign HTML Emails + +GPG signing works with both plain text and HTML emails: + +```bash +nylas email send \ + --to recipient@example.com \ + --subject "HTML Signed Email" \ + --body "

Signed HTML

" \ + --sign +``` + +### Auto-Sign All Emails + +Enable auto-sign to automatically sign all outgoing emails: + +```bash +# Enable auto-sign +nylas config set gpg.auto_sign true + +# Now all emails are signed automatically (no --sign flag needed) +nylas email send \ + --to recipient@example.com \ + --subject "Auto-signed Email" \ + --body "This email is automatically signed." + +# Disable auto-sign +nylas config set gpg.auto_sign false +``` + +**Note:** Auto-sign uses the configured default key (Nylas config or git config). You can still override with `--gpg-key` flag. + +--- + +## How It Works + +1. **Build Email**: CLI constructs the email content +2. **Sign with GPG**: Content is signed using your private key +3. **Build PGP/MIME**: Email is wrapped in RFC 3156 PGP/MIME structure +4. **Send**: Signed email is sent via Nylas API as raw MIME + +The result is a `multipart/signed` email with: +- Part 1: Original email content +- Part 2: Detached PGP signature (`application/pgp-signature`) + +--- + +## Verification + +### In Email Clients + +Recipients can verify your signature in compatible email clients: + +**Gmail**: Shows a signed icon and "This message is signed" banner + +**Thunderbird/Outlook**: Shows signature verification with your key details + +**Apple Mail**: Shows signed badge in message header + +### Manual Verification + +To manually verify a signed email: + +1. Save the raw email to a file (`.eml`) +2. Extract the signature and content +3. Verify with GPG: + +```bash +gpg --verify signature.asc content.txt +``` + +--- + +## Troubleshooting + +### "GPG not found" + +**Problem:** GPG is not installed or not in PATH + +**Solution:** +```bash +# Linux +sudo apt install gnupg + +# macOS +brew install gnupg + +# Verify installation +gpg --version +``` + +### "No default GPG key configured" + +**Problem:** No default signing key set in git config + +**Solution:** +```bash +# List your keys +gpg --list-secret-keys --keyid-format=long + +# Set default key +git config --global user.signingkey YOUR_KEY_ID +``` + +### "GPG signing failed: Inappropriate ioctl for device" + +**Problem:** GPG can't prompt for passphrase in non-interactive environment + +**Solution:** +```bash +# Configure gpg-agent to cache passphrase +echo "use-agent" >> ~/.gnupg/gpg.conf + +# Start gpg-agent +gpg-agent --daemon + +# Alternative: Remove passphrase from key (less secure) +gpg --edit-key YOUR_KEY_ID +# Then: passwd -> (enter old passphrase) -> (leave new passphrase empty) +``` + +### "GPG key not found or not usable for signing" + +**Problem:** Specified key ID doesn't exist or can't sign + +**Solution:** +```bash +# List all secret keys +nylas email send --list-gpg-keys + +# Use a valid key ID +nylas email send --to user@example.com --subject "Test" --sign --gpg-key VALID_KEY_ID +``` + +### Recipients can't verify signature + +**Problem:** Recipients don't have your public key + +**Solution:** Export and share your public key: + +```bash +# Export public key +gpg --armor --export YOUR_KEY_ID > my-public-key.asc + +# Upload to key server +gpg --send-keys YOUR_KEY_ID --keyserver keys.openpgp.org +``` + +--- + +## Best Practices + +### Security + +- **Protect your private key**: Never share your private key +- **Use strong passphrase**: Protect your key with a strong passphrase +- **Set expiration dates**: Keys should expire and be renewed periodically +- **Backup your key**: Keep secure backups of your private key + +### Key Management + +- **One key per identity**: Use different keys for personal/work email +- **Publish public key**: Upload to key servers for easy verification +- **Revoke compromised keys**: If your key is compromised, revoke it immediately + +### Usage + +- **Sign important emails**: Use signing for contracts, financial communications +- **Don't sign spam**: Recipients may flag unsigned emails from you as suspicious +- **Test first**: Send a signed test email to yourself to verify setup + +--- + +## Limitations + +### Current Limitations + +- **Signing only**: Emails are not encrypted (content is readable) +- **No S/MIME**: Only PGP/MIME format is supported +- **Manual verification**: Some email clients don't auto-verify signatures + +### Future Enhancements + +Planned features (not yet implemented): +- Email encryption with recipient's public key +- S/MIME certificate support + +--- + +## Technical Details + +### MIME Structure + +Signed emails use RFC 3156 PGP/MIME format: + +``` +Content-Type: multipart/signed; protocol="application/pgp-signature"; + micalg=pgp-sha256; boundary="boundary" + +--boundary +Content-Type: text/plain; charset=utf-8 + +[Email body] + +--boundary +Content-Type: application/pgp-signature; name="signature.asc" + +-----BEGIN PGP SIGNATURE----- +[Signature data] +-----END PGP SIGNATURE----- +--boundary-- +``` + +### Signature Algorithm + +- **Default**: SHA256 with RSA +- **Configurable**: Determined by your GPG key type +- **micalg**: Set automatically based on hash algorithm + +### Key Selection Priority + +The CLI determines which GPG key to use in this order: + +1. `--gpg-key ` flag (highest priority - explicit override) +2. `gpg.default_key` from Nylas config (`nylas config set gpg.default_key`) +3. From email address (auto-detected from grant) +4. `user.signingkey` from git config (lowest priority - fallback) +5. Error if no key available + +--- + +## Examples + +### Basic Signed Email + +```bash +nylas email send \ + --to colleague@company.com \ + --subject "Q4 Report" \ + --body "Please review the attached Q4 financial report." \ + --sign +``` + +### Signed with Attachment + +```bash +# (Note: Attachment support requires additional flags - see email send --help) +nylas email send \ + --to finance@company.com \ + --subject "Invoice #12345" \ + --body "Attached is the signed invoice." \ + --sign \ + --gpg-key 601FEE9B1D60185F +``` + +### Scheduled Signed Email + +```bash +nylas email send \ + --to team@company.com \ + --subject "Weekly Update" \ + --body "Here's this week's status update." \ + --sign \ + --schedule "tomorrow 9am" +``` + +--- + +## Related Commands + +- `nylas email send --help` - See all email sending options +- `gpg --list-keys` - List all GPG keys +- `gpg --gen-key` - Generate new GPG key +- `git config user.signingkey` - Set default signing key + +--- + +**Last Updated:** 2026-02-01 diff --git a/docs/commands/explain-gpg.md b/docs/commands/explain-gpg.md new file mode 100644 index 0000000..950b501 --- /dev/null +++ b/docs/commands/explain-gpg.md @@ -0,0 +1,969 @@ +# Understanding GPG: A Complete Guide + +A comprehensive guide to GPG (GNU Privacy Guard) cryptography, email signing, and verification. + +--- + +## Table of Contents + +1. [What is GPG?](#what-is-gpg) +2. [Asymmetric Cryptography](#asymmetric-cryptography) +3. [GPG Key Anatomy](#gpg-key-anatomy) +4. [Digital Signatures](#digital-signatures) +5. [Email Signing (PGP/MIME)](#email-signing-pgpmime) +6. [Signature Verification](#signature-verification) +7. [Trust Model](#trust-model) +8. [Key Servers](#key-servers) +9. [CLI Integration](#cli-integration) +10. [Security Considerations](#security-considerations) + +--- + +## What is GPG? + +**GPG (GNU Privacy Guard)** is a free implementation of the OpenPGP standard (RFC 4880). It provides: + +- **Digital Signatures** - Prove authorship and integrity +- **Encryption** - Protect confidential data +- **Key Management** - Create, store, and share cryptographic keys + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ GPG ECOSYSTEM │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ OpenPGP │ │ GPG │ │ Key Servers │ │ +│ │ Standard │───▶│ Software │◀──▶│ (Public) │ │ +│ │ (RFC 4880) │ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Your Keyring │ │ +│ │ ┌────────────┐ │ │ +│ │ │Private Keys│ │ │ +│ │ │Public Keys │ │ │ +│ │ └────────────┘ │ │ +│ └──────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### History + +| Year | Event | +|------|-------| +| 1991 | Phil Zimmermann creates PGP (Pretty Good Privacy) | +| 1997 | OpenPGP standard published (RFC 2440) | +| 1999 | GPG 1.0 released as free software | +| 2007 | RFC 4880 updates OpenPGP standard | +| Today | GPG is the de facto standard for email encryption/signing | + +--- + +## Asymmetric Cryptography + +GPG uses **asymmetric (public-key) cryptography**, which uses two mathematically related keys: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ KEY PAIR RELATIONSHIP │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ PRIVATE KEY │ │ PUBLIC KEY │ │ +│ │ (Secret) │ │ (Shareable) │ │ +│ │ │ │ │ │ +│ │ 🔐 Keep safe! │ │ 📢 Share it! │ │ +│ │ Never share │ │ Upload to │ │ +│ │ │ │ key servers │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +│ │ Mathematically │ │ +│ │◀──────Related────────▶│ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ SIGNS │ │ VERIFIES │ │ +│ │ DECRYPTS │ │ ENCRYPTS │ │ +│ └─────────────────┘ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### How It Works + +**Signing (proves identity):** +``` +┌──────────┐ Private Key ┌───────────┐ +│ Document │────────────────────▶│ Signature │ +└──────────┘ (Only you have) └───────────┘ +``` + +**Verification (anyone can verify):** +``` +┌──────────┐ + ┌───────────┐ Public Key ┌─────────┐ +│ Document │ │ Signature │─────────────────▶│ Valid? │ +└──────────┘ └───────────┘ (Anyone has) │ Yes/No │ + └─────────┘ +``` + +### Common Algorithms + +| Algorithm | Type | Key Size | Security Level | +|-----------|------|----------|----------------| +| RSA | Sign + Encrypt | 2048-4096 bits | Strong | +| DSA | Sign only | 2048-3072 bits | Strong | +| EdDSA (Ed25519) | Sign only | 256 bits | Very Strong | +| ECDH | Encrypt only | 256-521 bits | Very Strong | + +--- + +## GPG Key Anatomy + +A GPG key is more than just cryptographic data. It's a structured package containing: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ GPG KEY STRUCTURE │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ PRIMARY KEY │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ Key ID: 601FEE9B1D60185F (last 16 hex chars) │ │ │ +│ │ │ Fingerprint: DBADDF54A44EB10E9714F386601FEE9B1D60185F│ │ │ +│ │ │ Algorithm: RSA 4096 │ │ │ +│ │ │ Created: 2024-01-15 │ │ │ +│ │ │ Expires: 2026-01-15 (optional) │ │ │ +│ │ │ Capabilities: [C]ertify, [S]ign │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────┐ ┌─────────────────┐ │ +│ │ USER IDs │ │ SUBKEYS │ │ SIGNATURES │ │ +│ │ │ │ │ │ │ │ +│ │ John Doe │ │ [E]ncrypt │ │ Self-signature │ │ +│ │ │ │ [S]ign │ │ Other users' │ │ +│ │ │ │ [A]uth │ │ certifications │ │ +│ │ John Doe │ │ │ │ │ │ +│ │ │ │ │ │ │ │ +│ └─────────────────┘ └─────────────┘ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Identifiers + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ KEY IDENTIFIER TYPES │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ FINGERPRINT (40 hex characters) - Most Secure │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ DBAD DF54 A44E B10E 9714 F386 601F EE9B 1D60 185F │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ LONG KEY ID (16 hex characters) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 601FEE9B1D60185F │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ SHORT KEY ID (8 hex characters) - NOT RECOMMENDED │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 1D60185F │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ⚠️ Short Key IDs can have collisions! Always use fingerprint │ +│ or long key ID for security-critical operations. │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### User IDs (UIDs) + +A key can have multiple User IDs, typically in the format: + +``` +Name (Comment) +``` + +**Examples:** +- `John Doe ` +- `John Doe (Work) ` +- `John Doe (Personal) ` + +--- + +## Digital Signatures + +### How Signing Works + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SIGNING PROCESS │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ STEP 1: Hash the Document │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ "Hello, this is my email..." │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌─────────────┐ │ │ +│ │ │ SHA-256 │ (Hash Function) │ │ +│ │ └──────┬──────┘ │ │ +│ │ ▼ │ │ +│ │ a1b2c3d4e5f6... (256-bit hash) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ STEP 2: Encrypt Hash with Private Key │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ a1b2c3d4e5f6... + 🔐 Private Key │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌─────────────┐ │ │ +│ │ │ RSA/DSA │ (Asymmetric Encryption) │ │ +│ │ └──────┬──────┘ │ │ +│ │ ▼ │ │ +│ │ -----BEGIN PGP SIGNATURE----- │ │ +│ │ iQJJBAEBCAAzFiEE263fVKROsQ... │ │ +│ │ -----END PGP SIGNATURE----- │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ The signature contains: │ +│ • Encrypted hash │ +│ • Key ID used for signing │ +│ • Timestamp │ +│ • Hash algorithm identifier (SHA256, SHA512, etc.) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Signature Properties + +| Property | What It Proves | +|----------|----------------| +| **Authenticity** | The message came from the key owner | +| **Integrity** | The message hasn't been modified | +| **Non-repudiation** | The signer cannot deny signing | +| **Timestamp** | When the signature was made | + +### Signature Types + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SIGNATURE TYPES │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. INLINE SIGNATURE (Clearsign) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ -----BEGIN PGP SIGNED MESSAGE----- │ │ +│ │ Hash: SHA256 │ │ +│ │ │ │ +│ │ This is my message text. │ │ +│ │ -----BEGIN PGP SIGNATURE----- │ │ +│ │ iQJJBAEBCAAzFiEE... │ │ +│ │ -----END PGP SIGNATURE----- │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ Use: Simple text messages, readable without GPG │ +│ │ +│ 2. DETACHED SIGNATURE (Separate file) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ document.pdf ←── Original file (unchanged) │ │ +│ │ document.pdf.sig ←── Signature file │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ Use: Binary files, documents, email (PGP/MIME) │ +│ │ +│ 3. PGP/MIME (Email standard - RFC 3156) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ multipart/signed │ │ +│ │ ├── Part 1: Original message (any content type) │ │ +│ │ └── Part 2: application/pgp-signature │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ Use: Email signing (supported by most email clients) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Email Signing (PGP/MIME) + +### RFC 3156 Standard + +PGP/MIME (RFC 3156) defines how to sign emails while preserving MIME structure: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PGP/MIME EMAIL STRUCTURE │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ From: sender@example.com │ +│ To: recipient@example.com │ +│ Subject: Signed Email │ +│ Content-Type: multipart/signed; │ +│ protocol="application/pgp-signature"; │ +│ micalg=pgp-sha256; │ +│ boundary="=_signed_abc123" │ +│ │ +│ --=_signed_abc123 │ +│ Content-Type: text/plain; charset=utf-8 │ +│ Content-Transfer-Encoding: quoted-printable │ +│ │ +│ This is the email body that is signed. │ +│ Any modifications will invalidate the signature. │ +│ │ +│ --=_signed_abc123 │ +│ Content-Type: application/pgp-signature; │ +│ name="signature.asc" │ +│ Content-Disposition: attachment; │ +│ filename="signature.asc" │ +│ │ +│ -----BEGIN PGP SIGNATURE----- │ +│ │ +│ iQJJBAEBCAAzFiEE263fVKROsQ6XFPOGYB/umx1gGF8FAmV1234AABQJEA │ +│ ... (base64 encoded signature data) ... │ +│ =Ab12 │ +│ -----END PGP SIGNATURE----- │ +│ │ +│ --=_signed_abc123-- │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Content-Type Parameters + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `protocol` | Signature format | `application/pgp-signature` | +| `micalg` | Hash algorithm | `pgp-sha256`, `pgp-sha512` | +| `boundary` | MIME part separator | Random string | + +### What Gets Signed + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WHAT IS SIGNED │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ❌ NOT SIGNED (Outer headers): │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ From: sender@example.com │ │ +│ │ To: recipient@example.com │ │ +│ │ Subject: Important Message │ │ +│ │ Date: Sat, 01 Feb 2026 12:00:00 +0000 │ │ +│ │ Content-Type: multipart/signed; ... │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ✅ SIGNED (First MIME part - exact bytes): │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Content-Type: text/plain; charset=utf-8\r\n │ │ +│ │ Content-Transfer-Encoding: quoted-printable\r\n │ │ +│ │ \r\n │ │ +│ │ This is the email body.\r\n │ │ +│ │ Every byte matters for verification. │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ⚠️ CRITICAL: Line endings must be CRLF (\r\n) │ +│ Any change to whitespace invalidates the signature! │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Signing Flow in Nylas CLI + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ NYLAS CLI SIGNING FLOW │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ $ nylas email send --to user@example.com --sign │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 1. DETERMINE SIGNING KEY │ │ +│ │ Priority order: │ │ +│ │ a) --gpg-key flag (explicit) │ │ +│ │ b) gpg.default_key from nylas config │ │ +│ │ c) Key matching sender's email │ │ +│ │ d) user.signingkey from git config │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 2. BUILD MIME CONTENT │ │ +│ │ • Create Content-Type header │ │ +│ │ • Encode body (quoted-printable) │ │ +│ │ • Normalize line endings to CRLF │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 3. CREATE SIGNATURE │ │ +│ │ $ gpg --detach-sign --armor │ │ +│ │ --local-user │ │ +│ │ --sender │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 4. ASSEMBLE PGP/MIME MESSAGE │ │ +│ │ • multipart/signed container │ │ +│ │ • Part 1: Original content │ │ +│ │ • Part 2: Detached signature │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 5. SEND VIA NYLAS API │ │ +│ │ POST /v3/grants/{id}/messages/send?type=mime │ │ +│ │ Body: Raw RFC 822 message │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Signature Verification + +### Verification Process + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ VERIFICATION PROCESS │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ INPUT: │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Signed Content │ │ Signature │ │ +│ │ (exact bytes) │ │ (from email) │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ STEP 1: Extract Key ID from Signature │ │ +│ │ │ │ +│ │ The signature contains the Key ID of the signer │ │ +│ │ (NOT from the email's From header!) │ │ +│ │ │ │ +│ │ Signature → Key ID: 601FEE9B1D60185F │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ STEP 2: Find Public Key │ │ +│ │ │ │ +│ │ Search order: │ │ +│ │ 1. Local keyring (~/.gnupg/pubring.kbx) │ │ +│ │ 2. If not found → Fetch from key servers │ │ +│ │ • keys.openpgp.org │ │ +│ │ • keyserver.ubuntu.com │ │ +│ │ • pgp.mit.edu │ │ +│ │ • keys.gnupg.net │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ STEP 3: Decrypt Signature │ │ +│ │ │ │ +│ │ Signature + Public Key → Original Hash │ │ +│ │ │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ +│ │ │ Encrypted │ + │ Public │ = │ Original │ │ │ +│ │ │ Hash │ │ Key │ │ Hash │ │ │ +│ │ └───────────┘ └───────────┘ └───────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ STEP 4: Hash the Content │ │ +│ │ │ │ +│ │ Signed Content → SHA-256 → Computed Hash │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ STEP 5: Compare Hashes │ │ +│ │ │ │ +│ │ Original Hash ══════════════════ Computed Hash │ │ +│ │ (from signature) (from content) │ │ +│ │ │ │ +│ │ │ │ │ │ +│ │ └──────────── = ? ────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌─────────────────┐ │ │ +│ │ │ MATCH = VALID │ │ │ +│ │ │ NO MATCH = BAD │ │ │ +│ │ └─────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Verification Results + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ VERIFICATION OUTCOMES │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ✅ GOOD SIGNATURE │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • Hashes match │ │ +│ │ • Content has not been modified │ │ +│ │ • Signature was made with the claimed key │ │ +│ │ │ │ +│ │ Output: │ │ +│ │ ──────────────────────────────────────────────────── │ │ +│ │ ✓ Good signature │ │ +│ │ ──────────────────────────────────────────────────── │ │ +│ │ Signer: John Doe │ │ +│ │ Key ID: 601FEE9B1D60185F │ │ +│ │ Trust: ultimate │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ❌ BAD SIGNATURE │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • Hashes do NOT match │ │ +│ │ • Content was modified after signing, OR │ │ +│ │ • Signature was tampered with │ │ +│ │ │ │ +│ │ Output: │ │ +│ │ ──────────────────────────────────────────────────── │ │ +│ │ ✗ BAD signature │ │ +│ │ ──────────────────────────────────────────────────── │ │ +│ │ Signer: John Doe │ │ +│ │ Key ID: 601FEE9B1D60185F │ │ +│ │ ⚠️ Content may have been tampered with! │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ⚠️ NO PUBLIC KEY │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • Cannot find the signer's public key │ │ +│ │ • Cannot verify the signature │ │ +│ │ │ │ +│ │ Solution: │ │ +│ │ gpg --keyserver keys.openpgp.org --recv-keys │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key ID vs From Address + +**Important Security Note:** + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ KEY IDENTIFICATION - SECURITY WARNING │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ The signing key is identified by the KEY ID embedded in the │ +│ signature, NOT by the email's From header! │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Email Header: │ │ +│ │ From: alice@example.com ←── Can be forged! │ │ +│ │ │ │ +│ │ Signature: │ │ +│ │ Key ID: 601FEE9B1D60185F ←── Cryptographically bound │ │ +│ │ UID: Bob ←── Actual signer │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ This means: │ +│ • An attacker can forge the From header │ +│ • But they CANNOT forge a valid signature without the key │ +│ • Always verify the signer's UID matches who you expect │ +│ │ +│ ⚠️ A "Good signature" only proves the key owner signed it. │ +│ It does NOT prove the From address is correct. │ +│ YOU must verify the signer's identity (UID) is trustworthy. │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Trust Model + +### Web of Trust + +GPG uses a decentralized trust model called the "Web of Trust": + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WEB OF TRUST │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ YOU │ +│ │ │ +│ [ULTIMATE] │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │Alice│ │ Bob │ │Carol│ │ +│ └──┬──┘ └──┬──┘ └──┬──┘ │ +│ [FULL] [FULL] [MARGINAL] │ +│ │ │ │ │ +│ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ │ +│ ▼ ▼ ▼ ▼ ▼ ▼ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │Dave │ │Eve │ │Frank│ │Grace│ │Henry│ │Ivy │ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ +│ │ +│ Trust flows through the network based on your direct trust │ +│ assignments and the signatures (certifications) between keys. │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Trust Levels + +| Level | Meaning | When Assigned | +|-------|---------|---------------| +| **Ultimate** | Your own keys | Automatically for your keys | +| **Full** | Completely trust this person's certifications | You verified their identity in person | +| **Marginal** | Somewhat trust their certifications | You have some confidence in them | +| **Undefined** | No trust decision made | Default for new keys | +| **Never** | Do not trust at all | Explicitly untrusted | + +### Key Validity + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ KEY VALIDITY CALCULATION │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ A key is considered VALID if: │ +│ │ +│ 1. You signed it yourself (ultimate trust), OR │ +│ │ +│ 2. It has enough trusted signatures: │ +│ • 1 fully trusted signature, OR │ +│ • 3 marginally trusted signatures │ +│ │ +│ Example: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Unknown Key: Frank │ │ +│ │ │ │ +│ │ Signed by: │ │ +│ │ • Alice (your FULL trust) ←── 1 full = VALID │ │ +│ │ │ │ +│ │ OR │ │ +│ │ │ │ +│ │ Signed by: │ │ +│ │ • Carol (MARGINAL) │ │ +│ │ • Dave (MARGINAL) │ │ +│ │ • Eve (MARGINAL) ←── 3 marginal = VALID │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Setting Trust + +```bash +# Edit key trust level +gpg --edit-key +gpg> trust +# Select trust level (1-5) +gpg> save + +# Sign someone's key (certify their identity) +gpg --sign-key +``` + +--- + +## Key Servers + +### How Key Servers Work + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ KEY SERVER NETWORK │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ │ +│ │ Your Computer │ │ +│ │ (GPG Client) │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌──────────────┼──────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ keys. │ │ keyserver. │ │ pgp. │ │ +│ │ openpgp.org │ │ ubuntu.com │ │ mit.edu │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └───────────────┼───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Synchronization│ │ +│ │ (SKS Pool) │ │ +│ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Server Operations + +```bash +# Upload your public key +gpg --keyserver keys.openpgp.org --send-keys + +# Download someone's public key +gpg --keyserver keys.openpgp.org --recv-keys + +# Search for a key by email +gpg --keyserver keys.openpgp.org --search-keys user@example.com + +# Refresh all keys (update signatures, revocations) +gpg --keyserver keys.openpgp.org --refresh-keys +``` + +### Key Servers Used by Nylas CLI + +| Server | Description | Privacy | +|--------|-------------|---------| +| `keys.openpgp.org` | Modern, privacy-focused | Email verification required | +| `keyserver.ubuntu.com` | Ubuntu's server | Open | +| `pgp.mit.edu` | MIT's classic server | Open | +| `keys.gnupg.net` | GnuPG project pool | Open | + +### Privacy Considerations + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ KEY SERVER PRIVACY │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Traditional Key Servers (MIT, Ubuntu, GnuPG): │ +│ • Anyone can upload a key with any email │ +│ • No email verification │ +│ • Cannot delete keys once uploaded │ +│ • All signatures and UIDs are public │ +│ │ +│ Modern Key Servers (keys.openpgp.org): │ +│ • Email verification required │ +│ • Can delete your key │ +│ • Only verified emails are searchable │ +│ • Third-party signatures not distributed │ +│ │ +│ ⚠️ Once uploaded, consider your key ID permanently public. │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## CLI Integration + +### Commands Reference + +```bash +# ═══════════════════════════════════════════════════════════════ +# SENDING SIGNED EMAILS +# ═══════════════════════════════════════════════════════════════ + +# Sign with default key (from git config or nylas config) +nylas email send --to user@example.com --subject "Hello" --body "..." --sign + +# Sign with specific key +nylas email send --to user@example.com --subject "Hello" --body "..." \ + --sign --gpg-key 601FEE9B1D60185F + +# List available signing keys +nylas email send --list-gpg-keys + +# ═══════════════════════════════════════════════════════════════ +# VERIFYING SIGNATURES +# ═══════════════════════════════════════════════════════════════ + +# Verify a signed email +nylas email read --verify + +# View raw MIME to inspect signature structure +nylas email read --mime + +# ═══════════════════════════════════════════════════════════════ +# CONFIGURATION +# ═══════════════════════════════════════════════════════════════ + +# Set default signing key +nylas config set gpg.default_key 601FEE9B1D60185F + +# Enable auto-sign for all emails +nylas config set gpg.auto_sign true + +# View current GPG settings +nylas config get gpg.default_key +nylas config get gpg.auto_sign +``` + +### Key Selection Priority + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ KEY SELECTION ORDER │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ When signing, the CLI selects a key in this order: │ +│ │ +│ 1. --gpg-key flag (highest priority) │ +│ └── Explicit key ID from command line │ +│ │ +│ 2. gpg.default_key from Nylas config │ +│ └── nylas config set gpg.default_key │ +│ │ +│ 3. Key matching sender's email address │ +│ └── Searches GPG keyring for matching UID │ +│ │ +│ 4. user.signingkey from git config (lowest priority) │ +│ └── git config --global user.signingkey │ +│ │ +│ 5. Error if no key found │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Security Considerations + +### Best Practices + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SECURITY BEST PRACTICES │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 🔐 KEY PROTECTION │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • Use a strong passphrase (20+ characters) │ │ +│ │ • Never share your private key │ │ +│ │ • Use gpg-agent to cache passphrase │ │ +│ │ • Store backup in secure offline location │ │ +│ │ • Consider hardware key (YubiKey, etc.) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ 🔑 KEY MANAGEMENT │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • Set expiration dates (1-2 years recommended) │ │ +│ │ • Create revocation certificate immediately │ │ +│ │ • Use subkeys for daily operations │ │ +│ │ • Rotate keys periodically │ │ +│ │ • Revoke compromised keys immediately │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ✅ VERIFICATION │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • Verify key fingerprints out-of-band (in person) │ │ +│ │ • Check the signer's UID, not just "Good signature" │ │ +│ │ • Be aware that From headers can be forged │ │ +│ │ • Maintain healthy skepticism │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Common Attacks + +| Attack | Description | Mitigation | +|--------|-------------|------------| +| **Key Spoofing** | Creating a key with someone else's name | Verify fingerprint in person | +| **From Header Forgery** | Sending email with fake From address | Check signer UID, not From header | +| **Signature Stripping** | Removing signature from email | Train users to expect signatures | +| **Downgrade Attack** | Convincing you to accept unsigned email | Require signatures for sensitive comms | + +### What Signatures Do NOT Prove + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ LIMITATIONS OF SIGNATURES │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ❌ Does NOT prove the From header is accurate │ +│ ❌ Does NOT prove the sender is who they claim to be │ +│ (only that they control the signing key) │ +│ ❌ Does NOT encrypt the message (content is still readable) │ +│ ❌ Does NOT hide metadata (To, Subject, Date visible) │ +│ ❌ Does NOT prevent the recipient from forwarding │ +│ │ +│ ✅ DOES prove the content hasn't been modified │ +│ ✅ DOES prove the key owner signed it │ +│ ✅ DOES provide timestamp of signing │ +│ ✅ DOES enable non-repudiation (signer can't deny signing) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Quick Reference + +### GPG Commands Cheat Sheet + +```bash +# Key Management +gpg --gen-key # Generate new key pair +gpg --list-keys # List public keys +gpg --list-secret-keys # List private keys +gpg --export -a KEY_ID > pub.asc # Export public key +gpg --import key.asc # Import a key +gpg --delete-key KEY_ID # Delete public key +gpg --delete-secret-key KEY_ID # Delete private key + +# Signing +gpg --sign file.txt # Create signed file +gpg --clearsign file.txt # Create inline signed file +gpg --detach-sign file.txt # Create detached signature +gpg --verify file.txt.sig file.txt # Verify detached signature + +# Key Servers +gpg --send-keys KEY_ID # Upload to key server +gpg --recv-keys KEY_ID # Download from key server +gpg --search-keys email@example.com # Search key server + +# Trust +gpg --edit-key KEY_ID # Edit key (trust, sign, etc.) +gpg --sign-key KEY_ID # Sign (certify) a key +``` + +### Environment Variables + +| Variable | Purpose | +|----------|---------| +| `GNUPGHOME` | GPG home directory (default: `~/.gnupg`) | +| `GPG_TTY` | Terminal for passphrase prompts | +| `GPG_AGENT_INFO` | GPG agent socket location | + +--- + +## Further Reading + +- [RFC 4880 - OpenPGP Message Format](https://tools.ietf.org/html/rfc4880) +- [RFC 3156 - MIME Security with OpenPGP](https://tools.ietf.org/html/rfc3156) +- [GnuPG Manual](https://www.gnupg.org/documentation/manuals/gnupg/) +- [Email Self-Defense (EFF)](https://emailselfdefense.fsf.org/) + +--- + +**Last Updated:** 2026-02-01 diff --git a/internal/adapters/gpg/service.go b/internal/adapters/gpg/service.go new file mode 100644 index 0000000..f8352ed --- /dev/null +++ b/internal/adapters/gpg/service.go @@ -0,0 +1,518 @@ +package gpg + +import ( + "bytes" + "context" + "fmt" + "net/mail" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// gpgKeyIDPattern matches valid GPG key IDs (8-40 hex characters) +var gpgKeyIDPattern = regexp.MustCompile(`^[A-Fa-f0-9]{8,40}$`) + +// isValidGPGKeyID validates that a key ID is in a safe format for use with GPG commands. +// Valid formats: +// - 8-40 hexadecimal characters (short key ID, long key ID, or fingerprint) +// - Valid email address (GPG allows email as key identifier) +func isValidGPGKeyID(keyID string) bool { + keyID = strings.TrimSpace(keyID) + if keyID == "" { + return false + } + + // Check if it's a valid hex key ID (8-40 chars) + if gpgKeyIDPattern.MatchString(keyID) { + return true + } + + // Check if it's a valid email address + _, err := mail.ParseAddress(keyID) + if err == nil { + return true + } + + // Also accept "Name " format + if strings.Contains(keyID, "<") && strings.Contains(keyID, ">") { + _, err := mail.ParseAddress(keyID) + return err == nil + } + + return false +} + +// Service provides GPG signing and verification operations. +type Service interface { + // CheckGPGAvailable verifies GPG is installed and accessible. + CheckGPGAvailable(ctx context.Context) error + + // ListSigningKeys lists all available secret keys for signing. + ListSigningKeys(ctx context.Context) ([]KeyInfo, error) + + // GetDefaultSigningKey gets the default signing key from git config. + GetDefaultSigningKey(ctx context.Context) (*KeyInfo, error) + + // FindKeyByEmail finds a signing key that contains the given email in its UIDs. + // Returns the key ID (not the email) for use with --local-user. + FindKeyByEmail(ctx context.Context, email string) (*KeyInfo, error) + + // SignData signs data with the specified key and returns a detached signature. + // senderEmail is optional - when provided, it embeds that email in the Signer's User ID subpacket. + SignData(ctx context.Context, keyID string, data []byte, senderEmail string) (*SignResult, error) + + // VerifyDetachedSignature verifies a detached signature against data. + // Returns verification result including signer info and trust level. + VerifyDetachedSignature(ctx context.Context, data []byte, signature []byte) (*VerifyResult, error) +} + +// service implements Service using the system GPG command. +type service struct{} + +// NewService creates a new GPG service. +func NewService() Service { + return &service{} +} + +// CheckGPGAvailable verifies GPG is installed. +func (s *service) CheckGPGAvailable(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "gpg", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("GPG not found. Install with: sudo apt install gnupg (Linux) or brew install gnupg (macOS)") + } + return nil +} + +// ListSigningKeys lists all secret keys available for signing. +func (s *service) ListSigningKeys(ctx context.Context) ([]KeyInfo, error) { + // Use --with-colons format for reliable parsing + cmd := exec.CommandContext(ctx, "gpg", "--list-secret-keys", "--with-colons", "--with-fingerprint") + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 { + return nil, fmt.Errorf("gpg list keys failed: %s", string(exitErr.Stderr)) + } + return nil, fmt.Errorf("gpg list keys failed: %w", err) + } + + return parseSecretKeys(string(output)) +} + +// GetDefaultSigningKey retrieves the default signing key from git config. +func (s *service) GetDefaultSigningKey(ctx context.Context) (*KeyInfo, error) { + // Try to get key from git config + cmd := exec.CommandContext(ctx, "git", "config", "--get", "user.signingkey") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("no default GPG key configured. Set with: git config --global user.signingkey ") + } + + keyID := strings.TrimSpace(string(output)) + if keyID == "" { + return nil, fmt.Errorf("git user.signingkey is empty") + } + + // Get full key info + keys, err := s.ListSigningKeys(ctx) + if err != nil { + return nil, err + } + + // Find matching key + for i := range keys { + if strings.HasSuffix(keys[i].Fingerprint, keyID) || keys[i].KeyID == keyID { + return &keys[i], nil + } + } + + return nil, fmt.Errorf("git signing key %s not found in GPG keyring", keyID) +} + +// FindKeyByEmail finds a signing key that contains the given email in its UIDs. +// Returns the KeyInfo with the actual key ID for use with --local-user. +// This is important because GPG's --sender option only works correctly when +// --local-user is a key ID, not an email address. +func (s *service) FindKeyByEmail(ctx context.Context, email string) (*KeyInfo, error) { + keys, err := s.ListSigningKeys(ctx) + if err != nil { + return nil, err + } + + // Normalize email for comparison + email = strings.ToLower(strings.TrimSpace(email)) + + // Find key with matching email in UIDs + for i := range keys { + for _, uid := range keys[i].UIDs { + // Extract email from UID (format: "Name ") + uidLower := strings.ToLower(uid) + if strings.Contains(uidLower, "<"+email+">") || uidLower == email { + return &keys[i], nil + } + } + } + + return nil, fmt.Errorf("no GPG key found for email %s", email) +} + +// SignData creates a detached signature for the given data. +// senderEmail is optional - when provided, it embeds that email in the Signer's User ID subpacket. +func (s *service) SignData(ctx context.Context, keyID string, data []byte, senderEmail string) (*SignResult, error) { + // Validate keyID to prevent command injection (SEC-001) + if !isValidGPGKeyID(keyID) { + return nil, fmt.Errorf("invalid GPG key ID format: %q", keyID) + } + + // Validate senderEmail if provided + if senderEmail != "" { + if _, err := mail.ParseAddress(senderEmail); err != nil { + return nil, fmt.Errorf("invalid sender email format: %q", senderEmail) + } + } + + // Build GPG arguments + args := []string{ + "--detach-sign", + "--armor", + "--local-user", keyID, + } + + // Add --sender to explicitly set Signer's User ID subpacket + // This ensures the correct email appears in the signature when + // the key has multiple UIDs + if senderEmail != "" { + args = append(args, "--sender", senderEmail) + } + + args = append(args, "--output", "-") + + // Use detached signature with ASCII armor + // #nosec G204 - keyID and senderEmail are validated above (isValidGPGKeyID, mail.ParseAddress) + cmd := exec.CommandContext(ctx, "gpg", args...) + + cmd.Stdin = bytes.NewReader(data) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := stderr.String() + if strings.Contains(errMsg, "No secret key") { + return nil, fmt.Errorf("GPG key %s not found or not usable for signing", keyID) + } + if strings.Contains(errMsg, "Timeout") || strings.Contains(errMsg, "timeout") { + return nil, fmt.Errorf("GPG passphrase prompt timed out. Please ensure gpg-agent is running") + } + return nil, fmt.Errorf("gpg signing failed: %s", errMsg) + } + + signature := stdout.Bytes() + if len(signature) == 0 { + return nil, fmt.Errorf("gpg produced empty signature") + } + + // Extract hash algorithm from signature (if present in stderr) + hashAlgo := extractHashAlgorithm(stderr.String()) + + return &SignResult{ + Signature: signature, + KeyID: keyID, + SignedAt: time.Now(), + HashAlgo: hashAlgo, + }, nil +} + +// KeyServers is the list of public key servers to try when fetching keys. +// Servers are tried in order until one succeeds. +var KeyServers = []string{ + "keys.openpgp.org", // Modern, privacy-focused + "keyserver.ubuntu.com", // Ubuntu's server, widely used + "pgp.mit.edu", // MIT's classic server + "keys.gnupg.net", // GnuPG project's pool +} + +// VerifyDetachedSignature verifies a detached signature against data. +func (s *service) VerifyDetachedSignature(ctx context.Context, data []byte, signature []byte) (*VerifyResult, error) { + // Create temporary files for data and signature + dataFile, err := createTempFile("gpg-verify-data-", data) + if err != nil { + return nil, fmt.Errorf("failed to create temp data file: %w", err) + } + defer func() { _ = dataFile.Close(); _ = removeFile(dataFile.Name()) }() + + sigFile, err := createTempFile("gpg-verify-sig-", signature) + if err != nil { + return nil, fmt.Errorf("failed to create temp signature file: %w", err) + } + defer func() { _ = sigFile.Close(); _ = removeFile(sigFile.Name()) }() + + // First attempt: verify with local keys + result, stderrOutput, err := s.runVerify(ctx, sigFile.Name(), dataFile.Name()) + + // If key not found, try to fetch from key server + if err != nil && (strings.Contains(stderrOutput, "No public key") || strings.Contains(stderrOutput, "Can't check signature: No public key")) { + // Extract key ID from error message + keyID := extractKeyIDFromError(stderrOutput) + // Validate extracted keyID before using (SEC-002) + if keyID != "" && gpgKeyIDPattern.MatchString(keyID) { + // Try to fetch the key from key server + if fetchErr := s.fetchKeyFromServer(ctx, keyID); fetchErr == nil { + // Retry verification with the newly imported key + result, stderrOutput, err = s.runVerify(ctx, sigFile.Name(), dataFile.Name()) + } + } + + // If still no key, return helpful error + if err != nil && (strings.Contains(stderrOutput, "No public key") || strings.Contains(stderrOutput, "Can't check signature: No public key")) { + return nil, fmt.Errorf("public key not found (tried %d key servers). Import manually with: gpg --keyserver keys.openpgp.org --recv-keys ", len(KeyServers)) + } + } + + if err != nil { + if strings.Contains(stderrOutput, "BAD signature") { + result.Valid = false + return result, nil + } + // For other errors, return the result if we got valid parsing + if result != nil && result.SignerKeyID != "" { + return result, nil + } + return nil, fmt.Errorf("gpg verification failed: %s", stderrOutput) + } + + return result, nil +} + +// runVerify executes gpg --verify and returns the parsed result. +func (s *service) runVerify(ctx context.Context, sigFile, dataFile string) (*VerifyResult, string, error) { + cmd := exec.CommandContext(ctx, "gpg", "--verify", "--status-fd", "1", sigFile, dataFile) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + statusOutput := stdout.String() + stderrOutput := stderr.String() + + result := parseVerifyOutput(statusOutput, stderrOutput) + return result, stderrOutput, err +} + +// fetchKeyFromServer attempts to fetch a public key from multiple key servers. +// It tries each server in order until one succeeds. +func (s *service) fetchKeyFromServer(ctx context.Context, keyID string) error { + var lastErr error + + for _, server := range KeyServers { + // #nosec G204 - keyID is validated by gpgKeyIDPattern.MatchString before this function is called + cmd := exec.CommandContext(ctx, "gpg", "--keyserver", server, "--recv-keys", keyID) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + lastErr = fmt.Errorf("failed to fetch from %s: %w", server, err) + continue + } + // Success + return nil + } + + return fmt.Errorf("failed to fetch key %s from any server: %w", keyID, lastErr) +} + +// extractKeyIDFromError extracts the key ID from GPG's "No public key" error message. +func extractKeyIDFromError(stderr string) string { + // GPG outputs: "gpg: using RSA key ABCD1234..." or "gpg: Can't check signature: No public key" + // Try to find key ID in the output + re := regexp.MustCompile(`using \w+ key ([A-F0-9]+)`) + if matches := re.FindStringSubmatch(stderr); len(matches) > 1 { + return matches[1] + } + + // Also try: "gpg: Good signature from..." followed by key ID + re = regexp.MustCompile(`key ([A-F0-9]{8,})`) + if matches := re.FindStringSubmatch(stderr); len(matches) > 1 { + return matches[1] + } + + return "" +} + +// createTempFile creates a temporary file with the given content. +func createTempFile(prefix string, content []byte) (*os.File, error) { + f, err := os.CreateTemp("", prefix) + if err != nil { + return nil, err + } + if _, err := f.Write(content); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return nil, err + } + return f, nil +} + +// removeFile removes a file, ignoring errors. +func removeFile(path string) error { + return os.Remove(path) +} + +// parseVerifyOutput parses GPG --status-fd output and stderr for verification info. +func parseVerifyOutput(statusOutput, stderrOutput string) *VerifyResult { + result := &VerifyResult{} + + // Parse status output for structured info + lines := strings.Split(statusOutput, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + + // [GNUPG:] GOODSIG + if strings.HasPrefix(line, "[GNUPG:] GOODSIG ") { + result.Valid = true + parts := strings.SplitN(line, " ", 4) + if len(parts) >= 3 { + result.SignerKeyID = parts[2] + } + if len(parts) >= 4 { + result.SignerUID = parts[3] + } + } + + // [GNUPG:] BADSIG + if strings.HasPrefix(line, "[GNUPG:] BADSIG ") { + result.Valid = false + parts := strings.SplitN(line, " ", 4) + if len(parts) >= 3 { + result.SignerKeyID = parts[2] + } + if len(parts) >= 4 { + result.SignerUID = parts[3] + } + } + + // [GNUPG:] VALIDSIG ... + if strings.HasPrefix(line, "[GNUPG:] VALIDSIG ") { + parts := strings.Fields(line) + if len(parts) >= 3 { + result.Fingerprint = parts[2] + } + if len(parts) >= 5 { + if ts, err := strconv.ParseInt(parts[4], 10, 64); err == nil { + result.SignedAt = time.Unix(ts, 0) + } + } + } + + // [GNUPG:] TRUST_ULTIMATE, TRUST_FULL, TRUST_MARGINAL, TRUST_UNDEFINED, TRUST_NEVER + if strings.HasPrefix(line, "[GNUPG:] TRUST_") { + parts := strings.Fields(line) + if len(parts) >= 2 { + trust := strings.TrimPrefix(parts[1], "TRUST_") + result.TrustLevel = strings.ToLower(trust) + } + } + } + + // Also check stderr for "Good signature" as fallback + if !result.Valid && strings.Contains(stderrOutput, "Good signature") { + result.Valid = true + } + + // Extract key ID from stderr if not found in status + if result.SignerKeyID == "" { + // Look for "using RSA key " pattern + re := regexp.MustCompile(`using \w+ key ([A-F0-9]+)`) + if matches := re.FindStringSubmatch(stderrOutput); len(matches) > 1 { + result.SignerKeyID = matches[1] + } + } + + // Extract signer UID from stderr if not found + if result.SignerUID == "" { + // Look for "Good signature from "Name "" pattern + re := regexp.MustCompile(`Good signature from "([^"]+)"`) + if matches := re.FindStringSubmatch(stderrOutput); len(matches) > 1 { + result.SignerUID = matches[1] + } + } + + return result +} + +// parseSecretKeys parses GPG --with-colons output format. +// Format: https://github.com/CSNW/gnupg/blob/master/doc/DETAILS +func parseSecretKeys(output string) ([]KeyInfo, error) { + var keys []KeyInfo + var currentKey *KeyInfo + + lines := strings.Split(output, "\n") + for _, line := range lines { + fields := strings.Split(line, ":") + if len(fields) < 2 { + continue + } + + recordType := fields[0] + + switch recordType { + case "sec": // Secret key + if currentKey != nil { + keys = append(keys, *currentKey) + } + currentKey = &KeyInfo{ + Trust: fields[1], + } + if len(fields) > 4 { + currentKey.KeyID = fields[4] + } + if len(fields) > 5 && fields[5] != "" { + if ts, err := strconv.ParseInt(fields[5], 10, 64); err == nil { + created := time.Unix(ts, 0) + currentKey.Created = created + } + } + if len(fields) > 6 && fields[6] != "" { + if ts, err := strconv.ParseInt(fields[6], 10, 64); err == nil { + expires := time.Unix(ts, 0) + currentKey.Expires = &expires + } + } + + case "fpr": // Fingerprint + if currentKey != nil && len(fields) > 9 { + currentKey.Fingerprint = fields[9] + } + + case "uid": // User ID + if currentKey != nil && len(fields) > 9 { + currentKey.UIDs = append(currentKey.UIDs, fields[9]) + } + } + } + + // Append last key + if currentKey != nil { + keys = append(keys, *currentKey) + } + + if len(keys) == 0 { + return nil, fmt.Errorf("no GPG secret keys found. Generate one with: gpg --gen-key") + } + + return keys, nil +} + +// extractHashAlgorithm extracts the hash algorithm from GPG stderr output. +func extractHashAlgorithm(stderr string) string { + // GPG outputs hash info like: "gpg: using RSA key ... digest algorithm SHA256" + re := regexp.MustCompile(`digest algorithm (\w+)`) + matches := re.FindStringSubmatch(stderr) + if len(matches) > 1 { + return strings.ToUpper(matches[1]) + } + return "SHA256" // Default assumption +} diff --git a/internal/adapters/gpg/service_test.go b/internal/adapters/gpg/service_test.go new file mode 100644 index 0000000..0662cab --- /dev/null +++ b/internal/adapters/gpg/service_test.go @@ -0,0 +1,543 @@ +package gpg + +import ( + "context" + "strings" + "testing" +) + +func TestParseSecretKeys(t *testing.T) { + tests := []struct { + name string + input string + want int + wantErr bool + }{ + { + name: "single key with UID", + input: `sec:u:4096:1:601FEE9B1D60185F:1609459200:::u:::scESC:::+:::23::0: +fpr:::::::::1234567890ABCDEF1234567890ABCDEF12345678: +uid:u::::1609459200::1234567890ABCDEF1234567890ABCDEF12345678::John Doe ::::::::::0: +`, + want: 1, + wantErr: false, + }, + { + name: "multiple keys", + input: `sec:u:4096:1:601FEE9B1D60185F:1609459200:::u:::scESC:::+:::23::0: +fpr:::::::::AAAA567890ABCDEF1234567890ABCDEF12345678: +uid:u::::1609459200::AAAA567890ABCDEF1234567890ABCDEF12345678::Alice ::::::::::0: +sec:u:2048:1:701FEE9B1D60185G:1609459200:::u:::scESC:::+:::23::0: +fpr:::::::::BBBB567890ABCDEF1234567890ABCDEF12345678: +uid:u::::1609459200::BBBB567890ABCDEF1234567890ABCDEF12345678::Bob ::::::::::0: +`, + want: 2, + wantErr: false, + }, + { + name: "no keys", + input: "", + want: 0, + wantErr: true, + }, + { + name: "invalid format", + input: "invalid output", + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSecretKeys(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseSecretKeys() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(got) != tt.want { + t.Errorf("parseSecretKeys() got %d keys, want %d", len(got), tt.want) + } + }) + } +} + +func TestExtractHashAlgorithm(t *testing.T) { + tests := []struct { + name string + stderr string + want string + }{ + { + name: "SHA256 in output", + stderr: "gpg: using RSA key ABC123 digest algorithm SHA256", + want: "SHA256", + }, + { + name: "SHA512 in output", + stderr: "gpg: using RSA key XYZ789 digest algorithm SHA512", + want: "SHA512", + }, + { + name: "no hash algorithm", + stderr: "gpg: signing failed", + want: "SHA256", // default + }, + { + name: "empty stderr", + stderr: "", + want: "SHA256", // default + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractHashAlgorithm(tt.stderr) + if got != tt.want { + t.Errorf("extractHashAlgorithm() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsValidGPGKeyID(t *testing.T) { + tests := []struct { + name string + keyID string + want bool + }{ + // Valid hex key IDs + {"8 char hex", "ABCD1234", true}, + {"16 char hex", "601FEE9B1D60185F", true}, + {"40 char fingerprint", "DBADDF54A44EB10E9714F386601FEE9B1D60185F", true}, + {"lowercase hex", "abcd1234", true}, + {"mixed case hex", "AbCd1234", true}, + + // Valid email addresses + {"simple email", "user@example.com", true}, + {"email with name", "John Doe ", true}, + {"email with subdomain", "user@mail.example.com", true}, + + // Invalid inputs + {"empty string", "", false}, + {"whitespace only", " ", false}, + {"too short hex", "ABC123", false}, // Less than 8 chars + {"too long hex", strings.Repeat("A", 41), false}, // More than 40 chars + {"non-hex chars", "GHIJ1234", false}, + {"command injection attempt", "KEY; rm -rf /", false}, + {"shell metachar", "KEY`whoami`", false}, + {"newline injection", "KEY\nmalicious", false}, + {"special chars", "KEY--malicious", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isValidGPGKeyID(tt.keyID) + if got != tt.want { + t.Errorf("isValidGPGKeyID(%q) = %v, want %v", tt.keyID, got, tt.want) + } + }) + } +} + +func TestCheckGPGAvailable(t *testing.T) { + ctx := context.Background() + svc := NewService() + + err := svc.CheckGPGAvailable(ctx) + if err != nil { + // GPG might not be installed in test environment + if !strings.Contains(err.Error(), "GPG not found") { + t.Errorf("CheckGPGAvailable() unexpected error = %v", err) + } + t.Skip("GPG not installed, skipping test") + } +} + +// Note: The following tests require GPG to be installed and configured. +// They will be skipped if GPG is not available. + +func TestListSigningKeys_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + keys, err := svc.ListSigningKeys(ctx) + if err != nil { + // It's OK if no keys exist + if !strings.Contains(err.Error(), "no GPG secret keys found") { + t.Errorf("ListSigningKeys() error = %v", err) + } + return + } + + if len(keys) == 0 { + t.Skip("No GPG keys found, skipping test") + } + + // Validate key structure + for _, key := range keys { + if key.KeyID == "" { + t.Error("Key missing KeyID") + } + if key.Fingerprint == "" { + t.Error("Key missing Fingerprint") + } + if len(key.UIDs) == 0 { + t.Error("Key missing UIDs") + } + } +} + +func TestGetDefaultSigningKey_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + key, err := svc.GetDefaultSigningKey(ctx) + if err != nil { + // It's OK if no default key is configured + if strings.Contains(err.Error(), "no default GPG key") || strings.Contains(err.Error(), "not found") { + t.Skip("No default GPG key configured, skipping test") + } + t.Errorf("GetDefaultSigningKey() error = %v", err) + return + } + + if key == nil { + t.Error("GetDefaultSigningKey() returned nil key") + return + } + + if key.KeyID == "" { + t.Error("Default key missing KeyID") + } + if key.Fingerprint == "" { + t.Error("Default key missing Fingerprint") + } +} + +func TestSignData_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + // Get default key + key, err := svc.GetDefaultSigningKey(ctx) + if err != nil { + t.Skip("No default GPG key configured, skipping test") + } + + // Sign test data (empty sender email - optional parameter) + testData := []byte("Hello, GPG world!") + result, err := svc.SignData(ctx, key.KeyID, testData, "") + if err != nil { + t.Fatalf("SignData() error = %v", err) + } + + if result == nil { + t.Fatal("SignData() returned nil result") + } + + // Validate signature + if len(result.Signature) == 0 { + t.Error("SignData() returned empty signature") + } + + // Check for PGP signature markers + sigStr := string(result.Signature) + if !strings.Contains(sigStr, "-----BEGIN PGP SIGNATURE-----") { + t.Error("Signature missing PGP BEGIN marker") + } + if !strings.Contains(sigStr, "-----END PGP SIGNATURE-----") { + t.Error("Signature missing PGP END marker") + } + + if result.KeyID == "" { + t.Error("SignResult missing KeyID") + } + if result.SignedAt.IsZero() { + t.Error("SignResult missing SignedAt timestamp") + } +} + +func TestParseVerifyOutput(t *testing.T) { + tests := []struct { + name string + statusOutput string + stderrOutput string + wantValid bool + wantKeyID string + wantUID string + wantTrust string + }{ + { + name: "good signature with ultimate trust", + statusOutput: `[GNUPG:] GOODSIG 601FEE9B1D60185F John Doe +[GNUPG:] VALIDSIG DBADDF54A44EB10E9714F386601FEE9B1D60185F 2026-02-01 1738425743 0 4 0 1 10 00 DBADDF54A44EB10E9714F386601FEE9B1D60185F +[GNUPG:] TRUST_ULTIMATE 0 pgp`, + stderrOutput: `gpg: Signature made Sun 01 Feb 2026 12:02:23 PM EST +gpg: using RSA key DBADDF54A44EB10E9714F386601FEE9B1D60185F +gpg: Good signature from "John Doe " [ultimate]`, + wantValid: true, + wantKeyID: "601FEE9B1D60185F", + wantUID: "John Doe ", + wantTrust: "ultimate", + }, + { + name: "bad signature", + statusOutput: `[GNUPG:] BADSIG 601FEE9B1D60185F John Doe `, + stderrOutput: `gpg: Signature made Sun 01 Feb 2026 12:02:23 PM EST +gpg: BAD signature from "John Doe " [ultimate]`, + wantValid: false, + wantKeyID: "601FEE9B1D60185F", + wantUID: "John Doe ", + wantTrust: "", + }, + { + name: "good signature from stderr only", + statusOutput: "", + stderrOutput: `gpg: Signature made Sun 01 Feb 2026 12:02:23 PM EST +gpg: using RSA key DBADDF54A44EB10E9714F386601FEE9B1D60185F +gpg: Good signature from "Alice " [full]`, + wantValid: true, + wantKeyID: "DBADDF54A44EB10E9714F386601FEE9B1D60185F", + wantUID: "Alice ", + wantTrust: "", + }, + { + name: "marginal trust", + statusOutput: `[GNUPG:] GOODSIG ABC123 Test User +[GNUPG:] TRUST_MARGINAL 0 pgp`, + stderrOutput: "", + wantValid: true, + wantKeyID: "ABC123", + wantUID: "Test User ", + wantTrust: "marginal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseVerifyOutput(tt.statusOutput, tt.stderrOutput) + + if result.Valid != tt.wantValid { + t.Errorf("Valid = %v, want %v", result.Valid, tt.wantValid) + } + if result.SignerKeyID != tt.wantKeyID { + t.Errorf("SignerKeyID = %v, want %v", result.SignerKeyID, tt.wantKeyID) + } + if result.SignerUID != tt.wantUID { + t.Errorf("SignerUID = %v, want %v", result.SignerUID, tt.wantUID) + } + if result.TrustLevel != tt.wantTrust { + t.Errorf("TrustLevel = %v, want %v", result.TrustLevel, tt.wantTrust) + } + }) + } +} + +func TestExtractKeyIDFromError(t *testing.T) { + tests := []struct { + name string + stderr string + want string + }{ + { + name: "RSA key in output", + stderr: "gpg: using RSA key DBADDF54A44EB10E9714F386601FEE9B1D60185F\ngpg: Can't check signature: No public key", + want: "DBADDF54A44EB10E9714F386601FEE9B1D60185F", + }, + { + name: "EdDSA key in output", + stderr: "gpg: using EdDSA key ABC123DEF456\ngpg: Can't check signature: No public key", + want: "ABC123DEF456", + }, + { + name: "key ID pattern", + stderr: "gpg: key 601FEE9B1D60185F: public key not found", + want: "601FEE9B1D60185F", + }, + { + name: "no key ID", + stderr: "gpg: some other error", + want: "", + }, + { + name: "empty stderr", + stderr: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractKeyIDFromError(tt.stderr) + if got != tt.want { + t.Errorf("extractKeyIDFromError() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestVerifyDetachedSignature_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + // Get default key + key, err := svc.GetDefaultSigningKey(ctx) + if err != nil { + t.Skip("No default GPG key configured, skipping test") + } + + // Sign test data + testData := []byte("Test message for verification") + signResult, err := svc.SignData(ctx, key.KeyID, testData, "") + if err != nil { + t.Fatalf("SignData() error = %v", err) + } + + // Verify the signature + verifyResult, err := svc.VerifyDetachedSignature(ctx, testData, signResult.Signature) + if err != nil { + t.Fatalf("VerifyDetachedSignature() error = %v", err) + } + + if !verifyResult.Valid { + t.Error("VerifyDetachedSignature() returned invalid for valid signature") + } + + if verifyResult.SignerKeyID == "" { + t.Error("VerifyResult missing SignerKeyID") + } + + if verifyResult.Fingerprint == "" { + t.Error("VerifyResult missing Fingerprint") + } +} + +func TestVerifyDetachedSignature_BadSignature_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + // Get default key + key, err := svc.GetDefaultSigningKey(ctx) + if err != nil { + t.Skip("No default GPG key configured, skipping test") + } + + // Sign test data + testData := []byte("Original message") + signResult, err := svc.SignData(ctx, key.KeyID, testData, "") + if err != nil { + t.Fatalf("SignData() error = %v", err) + } + + // Try to verify with modified data + modifiedData := []byte("Modified message") + verifyResult, err := svc.VerifyDetachedSignature(ctx, modifiedData, signResult.Signature) + if err != nil { + t.Fatalf("VerifyDetachedSignature() error = %v", err) + } + + if verifyResult.Valid { + t.Error("VerifyDetachedSignature() returned valid for tampered data") + } +} + +func TestFindKeyByEmail_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + // List keys to find an email to test with + keys, err := svc.ListSigningKeys(ctx) + if err != nil || len(keys) == 0 { + t.Skip("No GPG keys available, skipping test") + } + + // Find a key with UIDs + var testEmail string + for _, key := range keys { + for _, uid := range key.UIDs { + // Extract email from UID (format: "Name ") + if start := strings.Index(uid, "<"); start != -1 { + if end := strings.Index(uid, ">"); end > start { + testEmail = uid[start+1 : end] + break + } + } + } + if testEmail != "" { + break + } + } + + if testEmail == "" { + t.Skip("No email found in GPG keys, skipping test") + } + + // Find key by email + foundKey, err := svc.FindKeyByEmail(ctx, testEmail) + if err != nil { + t.Fatalf("FindKeyByEmail() error = %v", err) + } + + if foundKey == nil { + t.Fatal("FindKeyByEmail() returned nil") + } + + if foundKey.KeyID == "" { + t.Error("Found key missing KeyID") + } +} diff --git a/internal/adapters/gpg/types.go b/internal/adapters/gpg/types.go new file mode 100644 index 0000000..3b87ac3 --- /dev/null +++ b/internal/adapters/gpg/types.go @@ -0,0 +1,33 @@ +package gpg + +import "time" + +// KeyInfo represents a GPG signing key. +type KeyInfo struct { + KeyID string // Short key ID (e.g., "601FEE9B1D60185F") + Fingerprint string // Full fingerprint + UIDs []string // User IDs (typically email addresses) + Trust string // Trust level (ultimate, full, marginal, unknown) + Expires *time.Time // Expiration date (nil if no expiration) + Type string // Key type (RSA, DSA, ECDSA, EdDSA) + Length int // Key length in bits + Created time.Time // Creation date +} + +// SignResult contains the result of a signing operation. +type SignResult struct { + Signature []byte // Detached signature (ASCII armored) + KeyID string // Key ID used for signing + SignedAt time.Time // Signature timestamp + HashAlgo string // Hash algorithm used (e.g., "SHA256") +} + +// VerifyResult contains the result of a signature verification. +type VerifyResult struct { + Valid bool // Whether the signature is valid + SignerKeyID string // Key ID that created the signature + SignerUID string // Primary UID of the signer (e.g., "Name ") + SignedAt time.Time // When the signature was created + TrustLevel string // Trust level (ultimate, full, marginal, unknown, undefined) + Fingerprint string // Full fingerprint of signing key +} diff --git a/internal/adapters/mime/builder.go b/internal/adapters/mime/builder.go new file mode 100644 index 0000000..0aa713a --- /dev/null +++ b/internal/adapters/mime/builder.go @@ -0,0 +1,426 @@ +package mime + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "mime" + "mime/quotedprintable" + "strings" + "time" + + "github.com/nylas/cli/internal/domain" +) + +// Builder constructs MIME messages. +type Builder interface { + // BuildSignedMessage builds a PGP/MIME signed message (RFC 3156). + BuildSignedMessage(req *SignedMessageRequest) ([]byte, error) + + // PrepareContentToSign prepares the MIME content part that will be signed. + // Returns the exact bytes that should be signed with GPG. + // This includes the part headers and encoded body with CRLF line endings. + PrepareContentToSign(body, contentType string, attachments []domain.Attachment) ([]byte, error) +} + +// SignedMessageRequest contains all data needed to build a signed email. +type SignedMessageRequest struct { + // Standard email fields + From []domain.EmailParticipant + To []domain.EmailParticipant + Cc []domain.EmailParticipant + Bcc []domain.EmailParticipant + ReplyTo []domain.EmailParticipant + Subject string + Body string + ContentType string // "text/plain" or "text/html" + + // PGP signature + Signature []byte // Detached signature from GPG (ASCII armored) + HashAlgo string // Hash algorithm (e.g., "SHA256") + + // PreparedContent is the exact content that was signed. + // If set, this is used instead of rebuilding the content part. + // This ensures the signed content matches what's in the message. + PreparedContent []byte + + // Optional + Attachments []domain.Attachment + Headers map[string]string + MessageID string + Date time.Time +} + +// builder implements Builder. +type builder struct{} + +// NewBuilder creates a new MIME builder. +func NewBuilder() Builder { + return &builder{} +} + +// BuildSignedMessage constructs a PGP/MIME signed message per RFC 3156. +func (b *builder) BuildSignedMessage(req *SignedMessageRequest) ([]byte, error) { + if err := validateSignedRequest(req); err != nil { + return nil, err + } + + var buf bytes.Buffer + + // Write top-level headers + if err := b.writeHeaders(&buf, req); err != nil { + return nil, err + } + + // Create multipart/signed boundary + signedBoundary := generateBoundary("signed") + + // Determine micalg parameter + micalg := getMicAlg(req.HashAlgo) + + // Write Content-Type for multipart/signed + buf.WriteString("Content-Type: multipart/signed; protocol=\"application/pgp-signature\";\r\n") + buf.WriteString(fmt.Sprintf("\tmicalg=%s; boundary=\"%s\"\r\n", micalg, signedBoundary)) + buf.WriteString("\r\n") + + // Write first part: the content to be signed + buf.WriteString("--" + signedBoundary + "\r\n") + + // Use PreparedContent if provided, otherwise build content inline + if len(req.PreparedContent) > 0 { + buf.Write(req.PreparedContent) + } else { + if err := b.writeContentPart(&buf, req); err != nil { + return nil, err + } + } + + // Write second part: the signature + buf.WriteString("\r\n--" + signedBoundary + "\r\n") + buf.WriteString("Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n") + buf.WriteString("Content-Description: OpenPGP digital signature\r\n") + buf.WriteString("Content-Disposition: attachment; filename=\"signature.asc\"\r\n") + buf.WriteString("\r\n") + buf.Write(req.Signature) + buf.WriteString("\r\n--" + signedBoundary + "--\r\n") + + return buf.Bytes(), nil +} + +// writeHeaders writes RFC 822 headers. +func (b *builder) writeHeaders(buf *bytes.Buffer, req *SignedMessageRequest) error { + // MIME-Version (required) + buf.WriteString("MIME-Version: 1.0\r\n") + + // From + if len(req.From) > 0 { + buf.WriteString("From: " + formatAddresses(req.From) + "\r\n") + } + + // To + buf.WriteString("To: " + formatAddresses(req.To) + "\r\n") + + // Cc + if len(req.Cc) > 0 { + buf.WriteString("Cc: " + formatAddresses(req.Cc) + "\r\n") + } + + // Bcc (Note: typically not included in headers for security) + // Omitting Bcc as per RFC 5322 best practices + + // Reply-To + if len(req.ReplyTo) > 0 { + buf.WriteString("Reply-To: " + formatAddresses(req.ReplyTo) + "\r\n") + } + + // Subject (encode if contains non-ASCII) + subject := encodeHeader(req.Subject) + buf.WriteString("Subject: " + subject + "\r\n") + + // Date + date := req.Date + if date.IsZero() { + date = time.Now() + } + buf.WriteString("Date: " + date.Format(time.RFC1123Z) + "\r\n") + + // Message-ID + if req.MessageID != "" { + buf.WriteString("Message-ID: <" + req.MessageID + ">\r\n") + } + + // Custom headers + for key, value := range req.Headers { + buf.WriteString(key + ": " + value + "\r\n") + } + + return nil +} + +// writeContentPart writes the signed content part (body + attachments if any). +func (b *builder) writeContentPart(buf *bytes.Buffer, req *SignedMessageRequest) error { + if len(req.Attachments) == 0 { + // Simple case: just body + return b.writeBodyPart(buf, req) + } + + // Complex case: multipart/mixed with body and attachments + mixedBoundary := generateBoundary("mixed") + buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n") + buf.WriteString("\r\n") + + // Write body + buf.WriteString("--" + mixedBoundary + "\r\n") + if err := b.writeBodyPart(buf, req); err != nil { + return err + } + + // Write attachments + for _, att := range req.Attachments { + buf.WriteString("\r\n--" + mixedBoundary + "\r\n") + if err := b.writeAttachmentPart(buf, &att); err != nil { + return err + } + } + + buf.WriteString("\r\n--" + mixedBoundary + "--") + return nil +} + +// writeBodyPart writes the email body. +func (b *builder) writeBodyPart(buf *bytes.Buffer, req *SignedMessageRequest) error { + contentType := req.ContentType + if contentType == "" { + contentType = "text/plain" + } + + buf.WriteString("Content-Type: " + contentType + "; charset=utf-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + buf.WriteString("\r\n") + + // Normalize line endings to CRLF (required by RFC 2045) + body := normalizeLineEndings(req.Body) + + // Encode body with quoted-printable + var qpBuf bytes.Buffer + qpWriter := quotedprintable.NewWriter(&qpBuf) + if _, err := qpWriter.Write([]byte(body)); err != nil { + return fmt.Errorf("failed to encode body: %w", err) + } + if err := qpWriter.Close(); err != nil { + return fmt.Errorf("failed to close quoted-printable writer: %w", err) + } + + buf.Write(qpBuf.Bytes()) + return nil +} + +// writeAttachmentPart writes an attachment part. +func (b *builder) writeAttachmentPart(buf *bytes.Buffer, att *domain.Attachment) error { + contentType := att.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + + // Build Content-Type header + buf.WriteString("Content-Type: " + contentType + ";\r\n") + buf.WriteString("\tname=\"" + encodeHeaderParam(att.Filename) + "\"\r\n") + + // Content-Transfer-Encoding + buf.WriteString("Content-Transfer-Encoding: base64\r\n") + + // Content-Disposition + disposition := "attachment" + if att.IsInline { + disposition = "inline" + } + buf.WriteString("Content-Disposition: " + disposition + ";\r\n") + buf.WriteString("\tfilename=\"" + encodeHeaderParam(att.Filename) + "\"\r\n") + + // Content-ID (for inline images) + if att.ContentID != "" { + buf.WriteString("Content-ID: <" + att.ContentID + ">\r\n") + } + + buf.WriteString("\r\n") + + // Encode content as base64 + encoded := base64.StdEncoding.EncodeToString(att.Content) + // Split into 76-character lines per RFC 2045 + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + buf.WriteString(encoded[i:end] + "\r\n") + } + + return nil +} + +// validateSignedRequest validates the signed message request. +func validateSignedRequest(req *SignedMessageRequest) error { + if len(req.To) == 0 { + return fmt.Errorf("recipient (To) is required") + } + if req.Subject == "" { + return fmt.Errorf("subject is required") + } + if req.Body == "" { + return fmt.Errorf("body is required") + } + if len(req.Signature) == 0 { + return fmt.Errorf("signature is required") + } + return nil +} + +// formatAddresses formats email participants as RFC 822 addresses. +func formatAddresses(participants []domain.EmailParticipant) string { + var addrs []string + for _, p := range participants { + if p.Name != "" { + // Name + encodedName := encodeHeader(p.Name) + addrs = append(addrs, fmt.Sprintf("%s <%s>", encodedName, p.Email)) + } else { + // email@example.com + addrs = append(addrs, p.Email) + } + } + return strings.Join(addrs, ", ") +} + +// encodeHeader encodes a header value with RFC 2047 if it contains non-ASCII. +func encodeHeader(value string) string { + if isASCII(value) { + return value + } + return mime.QEncoding.Encode("utf-8", value) +} + +// encodeHeaderParam encodes a header parameter value. +func encodeHeaderParam(value string) string { + if isASCII(value) { + return value + } + // Use RFC 2231 encoding for non-ASCII parameters + return mime.QEncoding.Encode("utf-8", value) +} + +// isASCII checks if a string contains only ASCII characters. +func isASCII(s string) bool { + for _, r := range s { + if r > 127 { + return false + } + } + return true +} + +// normalizeLineEndings converts all line endings to CRLF (required by RFC 2045). +func normalizeLineEndings(s string) string { + // Replace CRLF with LF first (to handle mixed line endings) + s = strings.ReplaceAll(s, "\r\n", "\n") + // Replace CR with LF (for old Mac line endings) + s = strings.ReplaceAll(s, "\r", "\n") + // Now replace all LF with CRLF + s = strings.ReplaceAll(s, "\n", "\r\n") + return s +} + +// getMicAlg returns the micalg parameter for multipart/signed. +func getMicAlg(hashAlgo string) string { + // Map hash algorithm to micalg value per RFC 3156 + switch strings.ToUpper(hashAlgo) { + case "SHA256": + return "pgp-sha256" + case "SHA512": + return "pgp-sha512" + case "SHA384": + return "pgp-sha384" + case "SHA1": + return "pgp-sha1" + default: + return "pgp-sha256" // Default + } +} + +// PrepareContentToSign prepares the MIME content part for signing. +// This returns the exact bytes that should be signed with GPG. +func (b *builder) PrepareContentToSign(body, contentType string, attachments []domain.Attachment) ([]byte, error) { + if contentType == "" { + contentType = "text/plain" + } + + var buf bytes.Buffer + + if len(attachments) == 0 { + // Simple case: just body with headers + buf.WriteString("Content-Type: " + contentType + "; charset=utf-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + buf.WriteString("\r\n") + + // Normalize line endings to CRLF + body = normalizeLineEndings(body) + + // Encode body with quoted-printable + var qpBuf bytes.Buffer + qpWriter := quotedprintable.NewWriter(&qpBuf) + if _, err := qpWriter.Write([]byte(body)); err != nil { + return nil, fmt.Errorf("failed to encode body: %w", err) + } + if err := qpWriter.Close(); err != nil { + return nil, fmt.Errorf("failed to close quoted-printable writer: %w", err) + } + + buf.Write(qpBuf.Bytes()) + } else { + // Complex case: multipart/mixed with body and attachments + mixedBoundary := generateBoundary("mixed") + buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n") + buf.WriteString("\r\n") + + // Write body part + buf.WriteString("--" + mixedBoundary + "\r\n") + buf.WriteString("Content-Type: " + contentType + "; charset=utf-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + buf.WriteString("\r\n") + + body = normalizeLineEndings(body) + var qpBuf bytes.Buffer + qpWriter := quotedprintable.NewWriter(&qpBuf) + if _, err := qpWriter.Write([]byte(body)); err != nil { + return nil, fmt.Errorf("failed to encode body: %w", err) + } + if err := qpWriter.Close(); err != nil { + return nil, fmt.Errorf("failed to close quoted-printable writer: %w", err) + } + buf.Write(qpBuf.Bytes()) + + // Write attachment parts + for _, att := range attachments { + buf.WriteString("\r\n--" + mixedBoundary + "\r\n") + if err := b.writeAttachmentPart(&buf, &att); err != nil { + return nil, err + } + } + + buf.WriteString("\r\n--" + mixedBoundary + "--") + } + + return buf.Bytes(), nil +} + +// generateBoundary generates a cryptographically random MIME boundary string. +func generateBoundary(prefix string) string { + // Use crypto/rand for unpredictable boundaries (SEC-003) + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Fallback to less random but still unique - should never happen + return fmt.Sprintf("=_%s_%d", prefix, b) + } + return fmt.Sprintf("=_%s_%s", prefix, hex.EncodeToString(b)) +} diff --git a/internal/adapters/mime/builder_test.go b/internal/adapters/mime/builder_test.go new file mode 100644 index 0000000..635cfd3 --- /dev/null +++ b/internal/adapters/mime/builder_test.go @@ -0,0 +1,376 @@ +package mime + +import ( + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func TestBuildSignedMessage_Simple(t *testing.T) { + builder := NewBuilder() + + req := &SignedMessageRequest{ + From: []domain.EmailParticipant{ + {Name: "Alice", Email: "alice@example.com"}, + }, + To: []domain.EmailParticipant{ + {Name: "Bob", Email: "bob@example.com"}, + }, + Subject: "Test Subject", + Body: "This is a test email body.", + ContentType: "text/plain", + Signature: []byte("-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----"), + HashAlgo: "SHA256", + Date: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + } + + result, err := builder.BuildSignedMessage(req) + if err != nil { + t.Fatalf("BuildSignedMessage() error = %v", err) + } + + resultStr := string(result) + + // Validate required headers + requiredHeaders := []string{ + "MIME-Version: 1.0", + "From: Alice ", + "To: Bob ", + "Subject: Test Subject", + "Content-Type: multipart/signed", + "protocol=\"application/pgp-signature\"", + "micalg=pgp-sha256", + } + + for _, header := range requiredHeaders { + if !strings.Contains(resultStr, header) { + t.Errorf("Missing required header: %s", header) + } + } + + // Validate signature is present + if !strings.Contains(resultStr, "BEGIN PGP SIGNATURE") { + t.Error("Signature not found in output") + } + + // Validate body is present + if !strings.Contains(resultStr, "This is a test email body") { + t.Error("Body not found in output") + } +} + +func TestBuildSignedMessage_WithAttachment(t *testing.T) { + builder := NewBuilder() + + req := &SignedMessageRequest{ + From: []domain.EmailParticipant{ + {Email: "sender@example.com"}, + }, + To: []domain.EmailParticipant{ + {Email: "recipient@example.com"}, + }, + Subject: "Email with attachment", + Body: "See attached file.", + ContentType: "text/plain", + Signature: []byte("-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----"), + HashAlgo: "SHA256", + Attachments: []domain.Attachment{ + { + Filename: "test.txt", + ContentType: "text/plain", + Content: []byte("Hello, world!"), + }, + }, + } + + result, err := builder.BuildSignedMessage(req) + if err != nil { + t.Fatalf("BuildSignedMessage() error = %v", err) + } + + resultStr := string(result) + + // Validate multipart/mixed is used + if !strings.Contains(resultStr, "multipart/mixed") { + t.Error("Expected multipart/mixed for email with attachments") + } + + // Validate attachment headers + if !strings.Contains(resultStr, "test.txt") { + t.Error("Attachment filename not found") + } + + // Validate Content-Transfer-Encoding for attachment + if !strings.Contains(resultStr, "Content-Transfer-Encoding: base64") { + t.Error("Expected base64 encoding for attachment") + } +} + +func TestBuildSignedMessage_HTMLBody(t *testing.T) { + builder := NewBuilder() + + req := &SignedMessageRequest{ + From: []domain.EmailParticipant{ + {Email: "sender@example.com"}, + }, + To: []domain.EmailParticipant{ + {Email: "recipient@example.com"}, + }, + Subject: "HTML Email", + Body: "

Hello

", + ContentType: "text/html", + Signature: []byte("-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----"), + HashAlgo: "SHA512", + } + + result, err := builder.BuildSignedMessage(req) + if err != nil { + t.Fatalf("BuildSignedMessage() error = %v", err) + } + + resultStr := string(result) + + // Validate HTML content type + if !strings.Contains(resultStr, "text/html") { + t.Error("Expected text/html content type") + } + + // Validate SHA512 micalg + if !strings.Contains(resultStr, "micalg=pgp-sha512") { + t.Error("Expected pgp-sha512 micalg") + } +} + +func TestBuildSignedMessage_WithCcBcc(t *testing.T) { + builder := NewBuilder() + + req := &SignedMessageRequest{ + From: []domain.EmailParticipant{ + {Email: "sender@example.com"}, + }, + To: []domain.EmailParticipant{ + {Email: "to@example.com"}, + }, + Cc: []domain.EmailParticipant{ + {Email: "cc@example.com"}, + }, + Bcc: []domain.EmailParticipant{ + {Email: "bcc@example.com"}, + }, + ReplyTo: []domain.EmailParticipant{ + {Email: "replyto@example.com"}, + }, + Subject: "Test Cc/Bcc", + Body: "Test body", + Signature: []byte("-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----"), + HashAlgo: "SHA256", + } + + result, err := builder.BuildSignedMessage(req) + if err != nil { + t.Fatalf("BuildSignedMessage() error = %v", err) + } + + resultStr := string(result) + + // Validate Cc header is present + if !strings.Contains(resultStr, "Cc: cc@example.com") { + t.Error("Cc header not found") + } + + // Validate Bcc header is NOT present (security best practice) + if strings.Contains(resultStr, "Bcc:") { + t.Error("Bcc header should not be included in MIME message") + } + + // Validate Reply-To header is present + if !strings.Contains(resultStr, "Reply-To: replyto@example.com") { + t.Error("Reply-To header not found") + } +} + +func TestBuildSignedMessage_Validation(t *testing.T) { + builder := NewBuilder() + + tests := []struct { + name string + req *SignedMessageRequest + wantErr string + }{ + { + name: "missing To", + req: &SignedMessageRequest{ + Subject: "Test", + Body: "Test", + Signature: []byte("sig"), + }, + wantErr: "recipient (To) is required", + }, + { + name: "missing Subject", + req: &SignedMessageRequest{ + To: []domain.EmailParticipant{ + {Email: "test@example.com"}, + }, + Body: "Test", + Signature: []byte("sig"), + }, + wantErr: "subject is required", + }, + { + name: "missing Body", + req: &SignedMessageRequest{ + To: []domain.EmailParticipant{ + {Email: "test@example.com"}, + }, + Subject: "Test", + Signature: []byte("sig"), + }, + wantErr: "body is required", + }, + { + name: "missing Signature", + req: &SignedMessageRequest{ + To: []domain.EmailParticipant{ + {Email: "test@example.com"}, + }, + Subject: "Test", + Body: "Test", + }, + wantErr: "signature is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := builder.BuildSignedMessage(tt.req) + if err == nil { + t.Error("Expected error but got nil") + return + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Expected error containing %q, got %q", tt.wantErr, err.Error()) + } + }) + } +} + +func TestFormatAddresses(t *testing.T) { + tests := []struct { + name string + input []domain.EmailParticipant + want string + }{ + { + name: "single address with name", + input: []domain.EmailParticipant{ + {Name: "John Doe", Email: "john@example.com"}, + }, + want: "John Doe ", + }, + { + name: "single address without name", + input: []domain.EmailParticipant{ + {Email: "jane@example.com"}, + }, + want: "jane@example.com", + }, + { + name: "multiple addresses", + input: []domain.EmailParticipant{ + {Name: "Alice", Email: "alice@example.com"}, + {Email: "bob@example.com"}, + }, + want: "Alice , bob@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatAddresses(tt.input) + if got != tt.want { + t.Errorf("formatAddresses() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIsASCII(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + { + name: "pure ASCII", + input: "Hello World", + want: true, + }, + { + name: "with unicode", + input: "Hello 世界", + want: false, + }, + { + name: "empty string", + input: "", + want: true, + }, + { + name: "emoji", + input: "Test 😊", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isASCII(tt.input) + if got != tt.want { + t.Errorf("isASCII(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestGetMicAlg(t *testing.T) { + tests := []struct { + name string + hashAlgo string + want string + }{ + {"SHA256", "SHA256", "pgp-sha256"}, + {"sha256 lowercase", "sha256", "pgp-sha256"}, + {"SHA512", "SHA512", "pgp-sha512"}, + {"SHA384", "SHA384", "pgp-sha384"}, + {"SHA1", "SHA1", "pgp-sha1"}, + {"unknown", "MD5", "pgp-sha256"}, // default + {"empty", "", "pgp-sha256"}, // default + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getMicAlg(tt.hashAlgo) + if got != tt.want { + t.Errorf("getMicAlg(%q) = %q, want %q", tt.hashAlgo, got, tt.want) + } + }) + } +} + +func TestGenerateBoundary(t *testing.T) { + boundary1 := generateBoundary("test") + boundary2 := generateBoundary("test") + + // Should contain prefix + if !strings.HasPrefix(boundary1, "=_test_") { + t.Errorf("Boundary should start with prefix: %s", boundary1) + } + + // Should be unique + if boundary1 == boundary2 { + t.Error("Generated boundaries should be unique") + } +} diff --git a/internal/adapters/nylas/demo_messages.go b/internal/adapters/nylas/demo_messages.go index 45ed009..6704798 100644 --- a/internal/adapters/nylas/demo_messages.go +++ b/internal/adapters/nylas/demo_messages.go @@ -166,6 +166,15 @@ func (d *DemoClient) SendMessage(ctx context.Context, grantID string, req *domai return msg, nil } +// SendRawMessage simulates sending a raw MIME message. +func (d *DemoClient) SendRawMessage(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error) { + return &domain.Message{ + ID: "sent-raw-demo-msg", + Date: time.Now(), + RawMIME: string(rawMIME), + }, nil +} + // UpdateMessage simulates updating a message. func (d *DemoClient) UpdateMessage(ctx context.Context, grantID, messageID string, req *domain.UpdateMessageRequest) (*domain.Message, error) { msg := &domain.Message{ID: messageID, Subject: "Updated Message"} diff --git a/internal/adapters/nylas/messages.go b/internal/adapters/nylas/messages.go index caa3833..c6d5233 100644 --- a/internal/adapters/nylas/messages.go +++ b/internal/adapters/nylas/messages.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "fmt" + "strings" "time" "github.com/nylas/cli/internal/domain" @@ -241,10 +242,26 @@ func convertMessage(m messageResponse) domain.Message { headers = append(headers, domain.Header{Name: h.Name, Value: h.Value}) } - // Decode raw MIME if present (Base64url-encoded by API) + // Decode raw MIME if present + // Note: Nylas API may return different base64 variants: + // - Standard base64 with padding (production API) + // - Base64url without padding (some contexts) + // We try multiple decoders to handle all cases. rawMIME := "" if m.RawMIME != "" { - decoded, err := base64.RawURLEncoding.DecodeString(m.RawMIME) + var decoded []byte + var err error + + // First, normalize URL-safe characters to standard base64 + normalized := strings.ReplaceAll(m.RawMIME, "-", "+") + normalized = strings.ReplaceAll(normalized, "_", "/") + + // Try standard encoding with padding first + decoded, err = base64.StdEncoding.DecodeString(normalized) + if err != nil { + // Try without padding (RawStdEncoding) + decoded, err = base64.RawStdEncoding.DecodeString(normalized) + } if err == nil { rawMIME = string(decoded) } diff --git a/internal/adapters/nylas/messages_send.go b/internal/adapters/nylas/messages_send.go index 216fda4..ceb27fd 100644 --- a/internal/adapters/nylas/messages_send.go +++ b/internal/adapters/nylas/messages_send.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "mime/multipart" "net/http" "github.com/nylas/cli/internal/domain" @@ -78,6 +80,60 @@ func (c *HTTPClient) SendMessage(ctx context.Context, grantID string, req *domai return &msg, nil } +// SendRawMessage sends a raw RFC 822 MIME message via the Nylas API. +// Uses multipart/form-data with type=mime query parameter per Nylas API v3 spec. +func (c *HTTPClient) SendRawMessage(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error) { + queryURL := fmt.Sprintf("%s/v3/grants/%s/messages/send?type=mime", c.baseURL, grantID) + + // Create multipart form data + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + // Add MIME field + part, err := writer.CreateFormField("mime") + if err != nil { + return nil, fmt.Errorf("failed to create form field: %w", err) + } + if _, err := io.Copy(part, bytes.NewReader(rawMIME)); err != nil { + return nil, fmt.Errorf("failed to write MIME data: %w", err) + } + + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + // Create HTTP request + httpReq, err := http.NewRequestWithContext(ctx, "POST", queryURL, &body) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", writer.FormDataContentType()) + c.setAuthHeader(httpReq) + + // Send request + resp, err := c.doRequest(ctx, httpReq) + if err != nil { + return nil, fmt.Errorf("%w: %v", domain.ErrNetworkError, err) + } + defer func() { _ = resp.Body.Close() }() + + // Check status code + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { + return nil, c.parseError(resp) + } + + // Parse response + var result struct { + Data messageResponse `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + msg := convertMessage(result.Data) + return &msg, nil +} + // ListScheduledMessages retrieves all scheduled messages for a grant. func (c *HTTPClient) ListScheduledMessages(ctx context.Context, grantID string) ([]domain.ScheduledMessage, error) { queryURL := fmt.Sprintf("%s/v3/grants/%s/messages/schedules", c.baseURL, grantID) diff --git a/internal/adapters/nylas/messages_send_raw_test.go b/internal/adapters/nylas/messages_send_raw_test.go new file mode 100644 index 0000000..9c2c5f0 --- /dev/null +++ b/internal/adapters/nylas/messages_send_raw_test.go @@ -0,0 +1,359 @@ +package nylas + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSendRawMessage_Success(t *testing.T) { + // Mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Validate request method and path + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "/v3/grants/test-grant/messages/send") { + t.Errorf("Unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("type") != "mime" { + t.Errorf("Expected type=mime query parameter") + } + + // Validate Content-Type is multipart/form-data + contentType := r.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "multipart/form-data") { + t.Errorf("Expected multipart/form-data, got %s", contentType) + } + + // Parse multipart form + if err := r.ParseMultipartForm(10 << 20); err != nil { + t.Fatalf("Failed to parse multipart form: %v", err) + } + + // Validate MIME field exists + mimeData := r.FormValue("mime") + if mimeData == "" { + t.Error("MIME field is empty") + } + if !strings.Contains(mimeData, "MIME-Version: 1.0") { + t.Error("MIME data missing MIME-Version header") + } + + // Return success response + resp := struct { + Data messageResponse `json:"data"` + }{ + Data: messageResponse{ + ID: "msg-123", + GrantID: "test-grant", + Object: "message", + Subject: "Test", + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Create client + client := NewHTTPClient() + client.SetBaseURL(server.URL) + client.SetCredentials("", "", "test-api-key") + + // Test raw MIME message + rawMIME := []byte("MIME-Version: 1.0\r\nFrom: test@example.com\r\nTo: recipient@example.com\r\nSubject: Test\r\n\r\nTest body") + + msg, err := client.SendRawMessage(context.Background(), "test-grant", rawMIME) + if err != nil { + t.Fatalf("SendRawMessage() error = %v", err) + } + + if msg == nil { + t.Fatal("SendRawMessage() returned nil message") + } + if msg.ID != "msg-123" { + t.Errorf("Expected message ID msg-123, got %s", msg.ID) + } + if msg.GrantID != "test-grant" { + t.Errorf("Expected grant ID test-grant, got %s", msg.GrantID) + } +} + +func TestSendRawMessage_EmptyMIME(t *testing.T) { + // Mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Parse multipart form + if err := r.ParseMultipartForm(10 << 20); err != nil { + t.Fatalf("Failed to parse multipart form: %v", err) + } + + // Return success even with empty MIME (server accepts it) + resp := struct { + Data messageResponse `json:"data"` + }{ + Data: messageResponse{ + ID: "msg-empty", + GrantID: "test-grant", + Object: "message", + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewHTTPClient() + client.SetBaseURL(server.URL) + client.SetCredentials("", "", "test-api-key") + + // Empty MIME should still work (server validates) + msg, err := client.SendRawMessage(context.Background(), "test-grant", []byte("")) + if err != nil { + t.Fatalf("SendRawMessage() with empty MIME error = %v", err) + } + if msg.ID != "msg-empty" { + t.Errorf("Expected message ID msg-empty, got %s", msg.ID) + } +} + +func TestSendRawMessage_APIError(t *testing.T) { + // Mock server that returns error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + resp := map[string]any{ + "error": map[string]any{ + "type": "invalid_request_error", + "message": "Invalid MIME format", + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewHTTPClient() + client.SetBaseURL(server.URL) + client.SetCredentials("", "", "test-api-key") + + rawMIME := []byte("invalid mime data") + + _, err := client.SendRawMessage(context.Background(), "test-grant", rawMIME) + if err == nil { + t.Fatal("Expected error for invalid MIME, got nil") + } + if !strings.Contains(err.Error(), "Invalid MIME format") { + t.Errorf("Expected 'Invalid MIME format' error, got: %v", err) + } +} + +func TestSendRawMessage_NetworkError(t *testing.T) { + // Use invalid URL to trigger network error + client := NewHTTPClient() + client.SetBaseURL("http://invalid-host-12345.example.com") + client.SetCredentials("", "", "test-api-key") + + rawMIME := []byte("MIME-Version: 1.0\r\n\r\nTest") + + _, err := client.SendRawMessage(context.Background(), "test-grant", rawMIME) + if err == nil { + t.Fatal("Expected network error, got nil") + } + // Should return domain.ErrNetworkError + if !strings.Contains(err.Error(), "network error") { + t.Errorf("Expected network error, got: %v", err) + } +} + +func TestSendRawMessage_InvalidJSON(t *testing.T) { + // Mock server that returns invalid JSON + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("invalid json")) + })) + defer server.Close() + + client := NewHTTPClient() + client.SetBaseURL(server.URL) + client.SetCredentials("", "", "test-api-key") + + rawMIME := []byte("MIME-Version: 1.0\r\n\r\nTest") + + _, err := client.SendRawMessage(context.Background(), "test-grant", rawMIME) + if err == nil { + t.Fatal("Expected JSON decode error, got nil") + } +} + +func TestSendRawMessage_MultipartFormConstruction(t *testing.T) { + // Test that multipart form is correctly constructed + var capturedBody []byte + var capturedContentType string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Capture body and content type + capturedContentType = r.Header.Get("Content-Type") + var err error + capturedBody, err = io.ReadAll(r.Body) + if err != nil { + t.Fatalf("Failed to read body: %v", err) + } + + // Return success + resp := struct { + Data messageResponse `json:"data"` + }{ + Data: messageResponse{ + ID: "msg-test", + GrantID: "test-grant", + Object: "message", + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewHTTPClient() + client.SetBaseURL(server.URL) + client.SetCredentials("", "", "test-api-key") + + testMIME := []byte("MIME-Version: 1.0\r\nSubject: Test\r\n\r\nBody") + + _, err := client.SendRawMessage(context.Background(), "test-grant", testMIME) + if err != nil { + t.Fatalf("SendRawMessage() error = %v", err) + } + + // Validate Content-Type has boundary + if !strings.HasPrefix(capturedContentType, "multipart/form-data; boundary=") { + t.Errorf("Invalid Content-Type: %s", capturedContentType) + } + + // Parse captured multipart body + parts := strings.Split(capturedContentType, "boundary=") + if len(parts) != 2 { + t.Fatal("Could not extract boundary from Content-Type") + } + boundary := parts[1] + + reader := multipart.NewReader(bytes.NewReader(capturedBody), boundary) + + // Read first part (should be "mime" field) + part, err := reader.NextPart() + if err != nil { + t.Fatalf("Failed to read first part: %v", err) + } + + if part.FormName() != "mime" { + t.Errorf("Expected form field 'mime', got '%s'", part.FormName()) + } + + mimeData, err := io.ReadAll(part) + if err != nil { + t.Fatalf("Failed to read mime data: %v", err) + } + + if !bytes.Equal(mimeData, testMIME) { + t.Errorf("MIME data mismatch.\nExpected: %s\nGot: %s", testMIME, mimeData) + } + + // Should be no more parts + _, err = reader.NextPart() + if err != io.EOF { + t.Error("Expected only one part in multipart form") + } +} + +func TestSendRawMessage_StatusCodes(t *testing.T) { + tests := []struct { + name string + statusCode int + wantErr bool + }{ + { + name: "200 OK", + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "201 Created", + statusCode: http.StatusCreated, + wantErr: false, + }, + { + name: "202 Accepted", + statusCode: http.StatusAccepted, + wantErr: false, + }, + { + name: "400 Bad Request", + statusCode: http.StatusBadRequest, + wantErr: true, + }, + { + name: "401 Unauthorized", + statusCode: http.StatusUnauthorized, + wantErr: true, + }, + { + name: "500 Internal Server Error", + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + + if tt.wantErr { + // Return error response + resp := map[string]any{ + "error": map[string]any{ + "type": "api_error", + "message": "Test error", + }, + } + _ = json.NewEncoder(w).Encode(resp) + } else { + // Return success response + resp := struct { + Data messageResponse `json:"data"` + }{ + Data: messageResponse{ + ID: "msg-test", + GrantID: "test-grant", + Object: "message", + }, + } + _ = json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewHTTPClient() + client.SetBaseURL(server.URL) + client.SetCredentials("", "", "test-api-key") + + rawMIME := []byte("MIME-Version: 1.0\r\n\r\nTest") + + _, err := client.SendRawMessage(context.Background(), "test-grant", rawMIME) + if (err != nil) != tt.wantErr { + t.Errorf("SendRawMessage() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/adapters/nylas/mock_client.go b/internal/adapters/nylas/mock_client.go index 1ad0631..9803e1a 100644 --- a/internal/adapters/nylas/mock_client.go +++ b/internal/adapters/nylas/mock_client.go @@ -59,6 +59,7 @@ type MockClient struct { GetMessagesWithParamsFunc func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) GetMessageFunc func(ctx context.Context, grantID, messageID string) (*domain.Message, error) SendMessageFunc func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) + SendRawMessageFunc func(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error) UpdateMessageFunc func(ctx context.Context, grantID, messageID string, req *domain.UpdateMessageRequest) (*domain.Message, error) DeleteMessageFunc func(ctx context.Context, grantID, messageID string) error GetThreadsFunc func(ctx context.Context, grantID string, params *domain.ThreadQueryParams) ([]domain.Thread, error) diff --git a/internal/adapters/nylas/mock_messages.go b/internal/adapters/nylas/mock_messages.go index b0eea0c..ab40f2e 100644 --- a/internal/adapters/nylas/mock_messages.go +++ b/internal/adapters/nylas/mock_messages.go @@ -84,6 +84,19 @@ func (m *MockClient) SendMessage(ctx context.Context, grantID string, req *domai }, nil } +// SendRawMessage sends a raw MIME message. +func (m *MockClient) SendRawMessage(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error) { + m.LastGrantID = grantID + if m.SendRawMessageFunc != nil { + return m.SendRawMessageFunc(ctx, grantID, rawMIME) + } + return &domain.Message{ + ID: "sent-raw-message-id", + GrantID: grantID, + RawMIME: string(rawMIME), + }, nil +} + // UpdateMessage updates message properties. func (m *MockClient) UpdateMessage(ctx context.Context, grantID, messageID string, req *domain.UpdateMessageRequest) (*domain.Message, error) { m.UpdateMessageCalled = true diff --git a/internal/cli/config/config.go b/internal/cli/config/config.go index acf3750..2053cc7 100644 --- a/internal/cli/config/config.go +++ b/internal/cli/config/config.go @@ -36,6 +36,12 @@ If the config file doesn't exist, sensible defaults are used automatically.`, # Set default grant ID nylas config set default_grant grant_abc123 + # Set GPG default key + nylas config set gpg.default_key 601FEE9B1D60185F + + # Enable auto-sign all emails + nylas config set gpg.auto_sign true + # Initialize config with defaults nylas config init`, } diff --git a/internal/cli/config/get.go b/internal/cli/config/get.go index 4d1e5cd..732b48b 100644 --- a/internal/cli/config/get.go +++ b/internal/cli/config/get.go @@ -21,7 +21,9 @@ Examples of keys: api.timeout api.base_url output.format - output.color`, + output.color + gpg.default_key + gpg.auto_sign`, Example: ` # Get API timeout nylas config get api.timeout @@ -29,7 +31,13 @@ Examples of keys: nylas config get default_grant # Get output format - nylas config get output.format`, + nylas config get output.format + + # Get GPG default key + nylas config get gpg.default_key + + # Get GPG auto-sign setting + nylas config get gpg.auto_sign`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := configStore.Load() @@ -85,10 +93,23 @@ func getConfigValue(cfg any, key string) (string, error) { } func snakeToPascal(s string) string { + // Common acronyms that should be fully uppercase + acronyms := map[string]string{ + "api": "API", + "ai": "AI", + "gpg": "GPG", + "id": "ID", + } + parts := strings.Split(s, "_") for i, part := range parts { if len(part) > 0 { - parts[i] = strings.ToUpper(part[:1]) + part[1:] + // Check if it's a known acronym + if upper, ok := acronyms[strings.ToLower(part)]; ok { + parts[i] = upper + } else { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } } } return strings.Join(parts, "") diff --git a/internal/cli/config/get_test.go b/internal/cli/config/get_test.go index fa983d9..c0b84da 100644 --- a/internal/cli/config/get_test.go +++ b/internal/cli/config/get_test.go @@ -23,7 +23,7 @@ func TestSnakeToPascal(t *testing.T) { { name: "snake_case three words", input: "api_base_url", - want: "ApiBaseUrl", + want: "APIBaseUrl", }, { name: "already camelCase", @@ -59,7 +59,7 @@ func TestSnakeToPascal(t *testing.T) { func TestGetConfigValue(t *testing.T) { // Test struct matching typical config structure - type ApiConfig struct { + type APIConfig struct { Timeout string BaseUrl string } @@ -72,7 +72,7 @@ func TestGetConfigValue(t *testing.T) { type TestConfig struct { Region string DefaultGrant string - Api *ApiConfig + API *APIConfig Output OutputConfig } @@ -103,7 +103,7 @@ func TestGetConfigValue(t *testing.T) { { name: "get nested field with pointer", cfg: &TestConfig{ - Api: &ApiConfig{ + API: &APIConfig{ Timeout: "90s", }, }, @@ -123,7 +123,7 @@ func TestGetConfigValue(t *testing.T) { { name: "get from nil pointer returns empty", cfg: &TestConfig{ - Api: nil, + API: nil, }, key: "api.timeout", want: "", @@ -149,7 +149,7 @@ func TestGetConfigValue(t *testing.T) { { name: "deeply nested field", cfg: &TestConfig{ - Api: &ApiConfig{ + API: &APIConfig{ BaseUrl: "https://api.us.nylas.com", }, }, diff --git a/internal/cli/config/gpg_test.go b/internal/cli/config/gpg_test.go new file mode 100644 index 0000000..4f79f84 --- /dev/null +++ b/internal/cli/config/gpg_test.go @@ -0,0 +1,189 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/domain" +) + +func TestGPGConfig_SetAndGet(t *testing.T) { + // Create temp config directory + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + store := config.NewFileStore(configPath) + + tests := []struct { + name string + key string + value string + wantErr bool + validate func(t *testing.T, cfg *domain.Config) + }{ + { + name: "set gpg.default_key", + key: "gpg.default_key", + value: "601FEE9B1D60185F", + validate: func(t *testing.T, cfg *domain.Config) { + if cfg.GPG == nil { + t.Fatal("GPG config is nil") + } + if cfg.GPG.DefaultKey != "601FEE9B1D60185F" { + t.Errorf("expected default_key=601FEE9B1D60185F, got %s", cfg.GPG.DefaultKey) + } + }, + }, + { + name: "set gpg.auto_sign to true", + key: "gpg.auto_sign", + value: "true", + validate: func(t *testing.T, cfg *domain.Config) { + if cfg.GPG == nil { + t.Fatal("GPG config is nil") + } + if !cfg.GPG.AutoSign { + t.Error("expected auto_sign=true, got false") + } + }, + }, + { + name: "set gpg.auto_sign to false", + key: "gpg.auto_sign", + value: "false", + validate: func(t *testing.T, cfg *domain.Config) { + if cfg.GPG == nil { + t.Fatal("GPG config is nil") + } + if cfg.GPG.AutoSign { + t.Error("expected auto_sign=false, got true") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Load or create config + cfg, err := store.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + // Set value + err = setConfigValue(cfg, tt.key, tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("setConfigValue() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + // Save config + if err := store.Save(cfg); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + // Reload config + cfg, err = store.Load() + if err != nil { + t.Fatalf("failed to reload config: %v", err) + } + + // Validate + if tt.validate != nil { + tt.validate(t, cfg) + } + + // Test get + value, err := getConfigValue(cfg, tt.key) + if err != nil { + t.Errorf("getConfigValue() error = %v", err) + return + } + + if value != tt.value { + t.Errorf("getConfigValue() = %v, want %v", value, tt.value) + } + }) + } +} + +func TestGPGConfig_Persistence(t *testing.T) { + // Create temp config directory + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + store := config.NewFileStore(configPath) + + // Initial config + cfg := domain.DefaultConfig() + cfg.GPG = &domain.GPGConfig{ + DefaultKey: "ABC123", + AutoSign: true, + } + + // Save + if err := store.Save(cfg); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + // Reload + loadedCfg, err := store.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + // Verify + if loadedCfg.GPG == nil { + t.Fatal("GPG config is nil after reload") + } + if loadedCfg.GPG.DefaultKey != "ABC123" { + t.Errorf("expected default_key=ABC123, got %s", loadedCfg.GPG.DefaultKey) + } + if !loadedCfg.GPG.AutoSign { + t.Error("expected auto_sign=true, got false") + } +} + +func TestGPGConfig_FileFormat(t *testing.T) { + // Create temp config directory + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + store := config.NewFileStore(configPath) + + // Create config with GPG settings + cfg := domain.DefaultConfig() + cfg.GPG = &domain.GPGConfig{ + DefaultKey: "601FEE9B1D60185F", + AutoSign: true, + } + + // Save + if err := store.Save(cfg); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + // Read raw file + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config file: %v", err) + } + + content := string(data) + + // Verify YAML format + expectedLines := []string{ + "gpg:", + " default_key: 601FEE9B1D60185F", + " auto_sign: true", + } + + for _, line := range expectedLines { + if !contains(content, line) { + t.Errorf("config file missing expected line: %s\nGot:\n%s", line, content) + } + } +} diff --git a/internal/cli/config/set.go b/internal/cli/config/set.go index bea70aa..d593736 100644 --- a/internal/cli/config/set.go +++ b/internal/cli/config/set.go @@ -27,7 +27,13 @@ The configuration file will be created if it doesn't exist.`, nylas config set output.format json # Set output color mode - nylas config set output.color never`, + nylas config set output.color never + + # Set GPG default signing key + nylas config set gpg.default_key 601FEE9B1D60185F + + # Enable auto-sign for all emails + nylas config set gpg.auto_sign true`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := configStore.Load() diff --git a/internal/cli/config/set_test.go b/internal/cli/config/set_test.go index b4853f4..92a27c9 100644 --- a/internal/cli/config/set_test.go +++ b/internal/cli/config/set_test.go @@ -128,7 +128,7 @@ func TestSetFieldValue(t *testing.T) { } func TestSetConfigValue(t *testing.T) { - type ApiConfig struct { + type APIConfig struct { Timeout string BaseUrl string Port int @@ -143,7 +143,7 @@ func TestSetConfigValue(t *testing.T) { type TestConfig struct { Region string DefaultGrant string - Api *ApiConfig + API *APIConfig Output OutputConfig } @@ -180,20 +180,20 @@ func TestSetConfigValue(t *testing.T) { key: "api.timeout", value: "120s", checkFunc: func(c *TestConfig) bool { - return c.Api != nil && c.Api.Timeout == "120s" + return c.API != nil && c.API.Timeout == "120s" }, }, { name: "set nested field in existing pointer", cfg: &TestConfig{ - Api: &ApiConfig{ + API: &APIConfig{ BaseUrl: "existing", }, }, key: "api.timeout", value: "90s", checkFunc: func(c *TestConfig) bool { - return c.Api.Timeout == "90s" && c.Api.BaseUrl == "existing" + return c.API.Timeout == "90s" && c.API.BaseUrl == "existing" }, }, { @@ -202,7 +202,7 @@ func TestSetConfigValue(t *testing.T) { key: "api.port", value: "8080", checkFunc: func(c *TestConfig) bool { - return c.Api != nil && c.Api.Port == 8080 + return c.API != nil && c.API.Port == 8080 }, }, { diff --git a/internal/cli/email/helpers.go b/internal/cli/email/helpers.go index 6271743..6e1b9b7 100644 --- a/internal/cli/email/helpers.go +++ b/internal/cli/email/helpers.go @@ -74,28 +74,16 @@ func printMessageRaw(msg domain.Message) { // printMessageMIMEWithProvider prints the raw RFC822/MIME format with provider-aware error messages. func printMessageMIMEWithProvider(msg domain.Message, provider domain.Provider) { + _ = provider // Provider info available for future use if needed + if msg.RawMIME == "" { fmt.Println(strings.Repeat("─", 60)) _, _ = common.Yellow.Println("No raw MIME data available") fmt.Println(strings.Repeat("─", 60)) - - // Show provider-specific message - switch provider { - case domain.ProviderMicrosoft: - fmt.Println("Microsoft/Outlook accounts do not support raw MIME retrieval.") - fmt.Println() - _, _ = common.Cyan.Println("Alternative: Use --headers to view email headers instead:") - fmt.Println(" nylas email read --headers") - case "": - fmt.Println("This message does not have MIME data available.") - fmt.Println("The API may not support MIME retrieval for this provider.") - fmt.Println() - _, _ = common.Cyan.Println("Tip: Use --headers to view email headers (works with all providers):") - fmt.Println(" nylas email read --headers") - default: - fmt.Println("This message does not have MIME data available.") - fmt.Println("The API may not support MIME retrieval for this message.") - } + fmt.Println("This message does not have MIME data available.") + fmt.Println() + _, _ = common.Cyan.Println("Tip: Use --headers to view email headers:") + fmt.Println(" nylas email read --headers") return } diff --git a/internal/cli/email/read.go b/internal/cli/email/read.go index 86503df..c93fe6a 100644 --- a/internal/cli/email/read.go +++ b/internal/cli/email/read.go @@ -18,6 +18,7 @@ func newReadCmd() *cobra.Command { var rawOutput bool var mimeOutput bool var headersOutput bool + var verifySignature bool cmd := &cobra.Command{ Use: "read [grant-id]", @@ -33,7 +34,8 @@ func newReadCmd() *cobra.Command { // Determine which fields to request var fields string switch { - case mimeOutput: + case mimeOutput, verifySignature: + // Both --mime and --verify need raw MIME data fields = "raw_mime" case headersOutput: fields = "include_headers" @@ -62,6 +64,20 @@ func newReadCmd() *cobra.Command { return struct{}{}, nil } + // Handle --verify flag + if verifySignature { + // Fetch full message for display (raw_mime request returns minimal fields) + fullMsg, err := client.GetMessage(ctx, grantID, messageID) + if err == nil { + printMessage(*fullMsg, true) + } + // Verify signature using raw MIME + if err := verifyGPGSignature(ctx, msg); err != nil { + return struct{}{}, fmt.Errorf("GPG verification failed: %w", err) + } + return struct{}{}, nil + } + // Display logic: --mime > --headers > --raw > default switch { case mimeOutput: @@ -99,6 +115,7 @@ func newReadCmd() *cobra.Command { cmd.Flags().BoolVar(&rawOutput, "raw", false, "Show raw email body without HTML processing") cmd.Flags().BoolVar(&mimeOutput, "mime", false, "Show raw RFC822/MIME message format") cmd.Flags().BoolVar(&headersOutput, "headers", false, "Show email headers (works with all providers)") + cmd.Flags().BoolVar(&verifySignature, "verify", false, "Verify GPG/PGP signature of the message") return cmd } diff --git a/internal/cli/email/read_verify.go b/internal/cli/email/read_verify.go new file mode 100644 index 0000000..843fb22 --- /dev/null +++ b/internal/cli/email/read_verify.go @@ -0,0 +1,341 @@ +package email + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "strings" + + "github.com/nylas/cli/internal/adapters/gpg" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" +) + +// verifyGPGSignature verifies the GPG signature of a PGP/MIME message. +func verifyGPGSignature(ctx context.Context, msg *domain.Message) error { + if msg.RawMIME == "" { + return fmt.Errorf("no raw MIME data available for verification") + } + + // Check if this is a signed message + contentType := extractFullContentType(msg.RawMIME) + if !strings.Contains(contentType, "multipart/signed") || + !strings.Contains(contentType, "application/pgp-signature") { + return fmt.Errorf("message is not PGP/MIME signed (Content-Type: %s)", contentType) + } + + // Parse the multipart message to extract body and signature + body, signature, err := parsePGPMIME(msg.RawMIME) + if err != nil { + return fmt.Errorf("failed to parse PGP/MIME message: %w", err) + } + + // Initialize GPG service + gpgSvc := gpg.NewService() + if err := gpgSvc.CheckGPGAvailable(ctx); err != nil { + return err + } + + // Verify the signature + spinner := common.NewSpinner("Verifying GPG signature...") + spinner.Start() + result, err := gpgSvc.VerifyDetachedSignature(ctx, body, signature) + spinner.Stop() + + if err != nil { + return err + } + + // Display verification result + printVerifyResult(result) + return nil +} + +// parsePGPMIME parses a PGP/MIME signed message and extracts the signed body and signature. +func parsePGPMIME(rawMIME string) (body []byte, signature []byte, err error) { + // Find the Content-Type header to get the boundary + fullContentType := extractFullContentType(rawMIME) + if fullContentType == "" { + return nil, nil, fmt.Errorf("Content-Type header not found") + } + + // Parse the Content-Type to extract boundary + _, params, err := mime.ParseMediaType(fullContentType) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse Content-Type '%s': %w", fullContentType, err) + } + + boundary := params["boundary"] + if boundary == "" { + return nil, nil, fmt.Errorf("no boundary found in Content-Type") + } + + // Find the body section (after headers) for multipart parsing + headerEnd := findHeaderEnd(rawMIME) + if headerEnd == -1 { + return nil, nil, fmt.Errorf("could not find end of headers") + } + + // Create a reader for the multipart body + bodySection := rawMIME[headerEnd:] + mr := multipart.NewReader(strings.NewReader(bodySection), boundary) + + var signedContent []byte + var signatureContent []byte + partNum := 0 + + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, nil, fmt.Errorf("failed to read MIME part: %w", err) + } + + partNum++ + + switch partNum { + case 1: + // First part: the signed content + // For PGP/MIME verification, we need the EXACT bytes that were signed. + // This includes the part headers. + signedContent, err = extractSignedContent(rawMIME, boundary) + if err != nil { + return nil, nil, fmt.Errorf("failed to extract signed content: %w", err) + } + case 2: + // Second part: the signature + partContent, err := io.ReadAll(part) + if err != nil { + return nil, nil, fmt.Errorf("failed to read signature part: %w", err) + } + + // Check if signature needs quoted-printable decoding + cte := part.Header.Get("Content-Transfer-Encoding") + if strings.EqualFold(cte, "quoted-printable") { + // Decode quoted-printable + qpReader := quotedprintable.NewReader(bytes.NewReader(partContent)) + signatureContent, err = io.ReadAll(qpReader) + if err != nil { + // If QP decoding fails, try manual decoding + signatureContent = decodeQuotedPrintable(partContent) + } + } else { + signatureContent = partContent + } + } + } + + if signedContent == nil { + return nil, nil, fmt.Errorf("could not find signed content part") + } + if signatureContent == nil { + return nil, nil, fmt.Errorf("could not find signature part") + } + + // Trim any trailing whitespace/newlines from signature + signatureContent = bytes.TrimSpace(signatureContent) + + return signedContent, signatureContent, nil +} + +// extractFullContentType extracts the complete Content-Type header including continuations. +func extractFullContentType(rawMIME string) string { + lines := strings.Split(rawMIME, "\n") + var contentTypeParts []string + inContentType := false + + for _, line := range lines { + // Remove trailing CR if present + line = strings.TrimSuffix(line, "\r") + + // Empty line marks end of headers + if line == "" { + break + } + + lineLower := strings.ToLower(line) + if strings.HasPrefix(lineLower, "content-type:") { + inContentType = true + value := strings.TrimSpace(line[len("Content-Type:"):]) + contentTypeParts = append(contentTypeParts, value) + } else if inContentType { + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + contentTypeParts = append(contentTypeParts, strings.TrimSpace(line)) + } else { + break + } + } + } + + return strings.Join(contentTypeParts, " ") +} + +// findHeaderEnd finds the index where headers end (double newline). +func findHeaderEnd(rawMIME string) int { + // Try CRLF first + idx := strings.Index(rawMIME, "\r\n\r\n") + if idx != -1 { + return idx + 4 + } + // Try LF + idx = strings.Index(rawMIME, "\n\n") + if idx != -1 { + return idx + 2 + } + return -1 +} + +// extractSignedContent extracts the exact bytes of the first MIME part for verification. +// PGP/MIME requires the EXACT bytes including headers, with CRLF line endings. +func extractSignedContent(rawMIME string, boundary string) ([]byte, error) { + boundaryMarker := "--" + boundary + + // Find the first boundary + firstBoundaryIdx := strings.Index(rawMIME, boundaryMarker) + if firstBoundaryIdx == -1 { + return nil, fmt.Errorf("could not find first boundary") + } + + // Move past the boundary line and its line ending + partStart := firstBoundaryIdx + len(boundaryMarker) + // Skip CRLF or LF after boundary + if partStart < len(rawMIME) { + if rawMIME[partStart] == '\r' && partStart+1 < len(rawMIME) && rawMIME[partStart+1] == '\n' { + partStart += 2 + } else if rawMIME[partStart] == '\n' { + partStart++ + } + } + + // Find the second boundary + secondBoundaryIdx := strings.Index(rawMIME[partStart:], boundaryMarker) + if secondBoundaryIdx == -1 { + return nil, fmt.Errorf("could not find second boundary") + } + + // Extract the signed content (everything between boundaries) + signedContent := rawMIME[partStart : partStart+secondBoundaryIdx] + + // The signed content should NOT include the CRLF immediately before the boundary + // PGP/MIME: the delimiter CRLF belongs to the boundary, not the content + signedContent = strings.TrimSuffix(signedContent, "\r\n") + signedContent = strings.TrimSuffix(signedContent, "\n") + + // Normalize line endings to CRLF for signature verification + // First convert any CRLF to LF (to handle mixed line endings) + signedContent = strings.ReplaceAll(signedContent, "\r\n", "\n") + // Then convert all LF to CRLF + signedContent = strings.ReplaceAll(signedContent, "\n", "\r\n") + + return []byte(signedContent), nil +} + +// decodeQuotedPrintable decodes quoted-printable encoded content. +// This is a fallback decoder that handles common patterns when the stdlib fails. +func decodeQuotedPrintable(data []byte) []byte { + result := string(data) + + // FIRST: Handle soft line breaks (= at end of line) + // The order is critical - soft breaks must be removed before decoding hex + result = strings.ReplaceAll(result, "=\r\n", "") + result = strings.ReplaceAll(result, "=\n", "") + + // THEN: Decode =XX hex sequences + // Process character by character to handle all hex codes + var decoded strings.Builder + i := 0 + for i < len(result) { + if result[i] == '=' && i+2 < len(result) { + // Check if this is a valid hex sequence + hex := result[i+1 : i+3] + if isHexPair(hex) { + b := hexToByte(hex) + decoded.WriteByte(b) + i += 3 + continue + } + } + decoded.WriteByte(result[i]) + i++ + } + + return []byte(decoded.String()) +} + +// isHexPair checks if a 2-character string is a valid hex pair. +func isHexPair(s string) bool { + if len(s) != 2 { + return false + } + for _, c := range s { + isDigit := c >= '0' && c <= '9' + isUpperHex := c >= 'A' && c <= 'F' + isLowerHex := c >= 'a' && c <= 'f' + if !isDigit && !isUpperHex && !isLowerHex { + return false + } + } + return true +} + +// hexToByte converts a 2-character hex string to a byte. +func hexToByte(s string) byte { + var result byte + for _, c := range s { + result <<= 4 + switch { + case c >= '0' && c <= '9': + result |= byte(c - '0') + case c >= 'A' && c <= 'F': + result |= byte(c - 'A' + 10) + case c >= 'a' && c <= 'f': + result |= byte(c - 'a' + 10) + } + } + return result +} + +// printVerifyResult displays the GPG verification result. +func printVerifyResult(result *gpg.VerifyResult) { + fmt.Println() + fmt.Println(strings.Repeat("─", 60)) + + if result.Valid { + _, _ = common.Green.Println("✓ Good signature") + } else { + _, _ = common.Red.Println("✗ BAD signature") + } + + fmt.Println(strings.Repeat("─", 60)) + + if result.SignerUID != "" { + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Signer:"), result.SignerUID) + } + if result.SignerKeyID != "" { + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Key ID:"), result.SignerKeyID) + } + if result.Fingerprint != "" { + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Fingerprint:"), result.Fingerprint) + } + if !result.SignedAt.IsZero() { + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Signed:"), result.SignedAt.Format("Mon, 02 Jan 2006 15:04:05 MST")) + } + if result.TrustLevel != "" { + trustColor := common.Yellow + switch result.TrustLevel { + case "ultimate", "full": + trustColor = common.Green + case "never": + trustColor = common.Red + } + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Trust:"), trustColor.Sprint(result.TrustLevel)) + } + + fmt.Println() +} diff --git a/internal/cli/email/read_verify_test.go b/internal/cli/email/read_verify_test.go new file mode 100644 index 0000000..2bc50ab --- /dev/null +++ b/internal/cli/email/read_verify_test.go @@ -0,0 +1,262 @@ +package email + +import ( + "strings" + "testing" +) + +func TestExtractFullContentType(t *testing.T) { + tests := []struct { + name string + rawMIME string + want string + }{ + { + name: "simple content-type", + rawMIME: `Content-Type: text/plain; charset=utf-8 + +Body here`, + want: "text/plain; charset=utf-8", + }, + { + name: "multipart with continuation", + rawMIME: `From: test@example.com +Content-Type: multipart/signed; protocol="application/pgp-signature"; + micalg=pgp-sha256; boundary="=_signed_123" + +Body here`, + want: `multipart/signed; protocol="application/pgp-signature"; micalg=pgp-sha256; boundary="=_signed_123"`, + }, + { + name: "content-type with CRLF", + rawMIME: "Content-Type: text/html\r\n\r\nBody", + want: "text/html", + }, + { + name: "no content-type", + rawMIME: "From: test@example.com\n\nBody", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractFullContentType(tt.rawMIME) + if got != tt.want { + t.Errorf("extractFullContentType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFindHeaderEnd(t *testing.T) { + tests := []struct { + name string + rawMIME string + want int + }{ + { + name: "LF line endings", + rawMIME: "Header: value\n\nBody", + want: 15, // Position after \n\n + }, + { + name: "CRLF line endings", + rawMIME: "Header: value\r\n\r\nBody", + want: 17, // Position after \r\n\r\n + }, + { + name: "no blank line", + rawMIME: "Header: value", + want: -1, + }, + { + name: "multiple headers", + rawMIME: "Header1: value1\nHeader2: value2\n\nBody", + want: 33, // Position after \n\n + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findHeaderEnd(tt.rawMIME) + if got != tt.want { + t.Errorf("findHeaderEnd() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractSignedContent(t *testing.T) { + tests := []struct { + name string + rawMIME string + boundary string + wantStart string // Check that content starts with this + wantEnd string // Check that content ends with this + wantErr bool + }{ + { + name: "simple signed content", + rawMIME: `Content-Type: multipart/signed; boundary="=_signed_123" + +--=_signed_123 +Content-Type: text/plain; charset=utf-8 + +Hello World +--=_signed_123 +Content-Type: application/pgp-signature + +-----BEGIN PGP SIGNATURE----- +... +-----END PGP SIGNATURE----- +--=_signed_123--`, + boundary: "=_signed_123", + wantStart: "Content-Type: text/plain", + wantEnd: "Hello World", + wantErr: false, + }, + { + name: "missing first boundary", + rawMIME: "No boundary here", + boundary: "=_signed_123", + wantErr: true, + }, + { + name: "missing second boundary", + rawMIME: `--=_signed_123 +Content-Type: text/plain + +Hello`, + boundary: "=_signed_123", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := extractSignedContent(tt.rawMIME, tt.boundary) + if (err != nil) != tt.wantErr { + t.Errorf("extractSignedContent() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + content := string(got) + if !strings.HasPrefix(content, tt.wantStart) { + t.Errorf("Content doesn't start with %q, got: %q", tt.wantStart, content[:min(50, len(content))]) + } + if !strings.HasSuffix(content, tt.wantEnd) { + t.Errorf("Content doesn't end with %q, got: %q", tt.wantEnd, content[max(0, len(content)-50):]) + } + // Verify CRLF line endings + if strings.Contains(content, "\n") && !strings.Contains(content, "\r\n") { + t.Error("Content should have CRLF line endings") + } + } + }) + } +} + +func TestDecodeQuotedPrintable(t *testing.T) { + tests := []struct { + name string + input []byte + want string + }{ + { + name: "plain text", + input: []byte("Hello World"), + want: "Hello World", + }, + { + name: "encoded newline", + input: []byte("Line1=0ALine2"), + want: "Line1\nLine2", + }, + { + name: "encoded equals", + input: []byte("a=3Db"), + want: "a=b", + }, + { + name: "soft line break", + input: []byte("Hello =\nWorld"), + want: "Hello World", + }, + { + name: "soft line break CRLF", + input: []byte("Hello =\r\nWorld"), + want: "Hello World", + }, + { + name: "PGP signature pattern", + input: []byte("-----BEGIN PGP SIGNATURE-----=0A=0AiQJJ=0A-----END PGP SIGNATURE-----"), + want: "-----BEGIN PGP SIGNATURE-----\n\niQJJ\n-----END PGP SIGNATURE-----", + }, + { + name: "mixed encoding", + input: []byte("Test=0Awith=3Dequals=0Aand newlines"), + want: "Test\nwith=equals\nand newlines", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := decodeQuotedPrintable(tt.input) + if string(got) != tt.want { + t.Errorf("decodeQuotedPrintable() = %q, want %q", string(got), tt.want) + } + }) + } +} + +func TestIsHexPair(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"0A", true}, + {"FF", true}, + {"3D", true}, + {"0a", true}, + {"ff", true}, + {"A", false}, // too short + {"AAA", false}, // too long + {"GG", false}, // invalid hex + {"0Z", false}, // invalid hex + {"", false}, // empty + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := isHexPair(tt.input) + if got != tt.want { + t.Errorf("isHexPair(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestHexToByte(t *testing.T) { + tests := []struct { + input string + want byte + }{ + {"00", 0x00}, + {"0A", 0x0A}, + {"0a", 0x0a}, + {"FF", 0xFF}, + {"ff", 0xff}, + {"3D", 0x3D}, + {"20", 0x20}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := hexToByte(tt.input) + if got != tt.want { + t.Errorf("hexToByte(%q) = %02X, want %02X", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/cli/email/send.go b/internal/cli/email/send.go index 5ff347e..ca3d613 100644 --- a/internal/cli/email/send.go +++ b/internal/cli/email/send.go @@ -10,6 +10,7 @@ import ( "strings" "time" + configAdapter "github.com/nylas/cli/internal/adapters/config" "github.com/nylas/cli/internal/cli/common" "github.com/nylas/cli/internal/domain" "github.com/nylas/cli/internal/ports" @@ -31,12 +32,20 @@ func newSendCmd() *cobra.Command { var trackLabel string var metadata []string var jsonOutput bool + var sign bool + var gpgKeyID string + var listGPGKeys bool cmd := &cobra.Command{ Use: "send [grant-id]", Short: "Send an email", Long: `Compose and send an email message. +Supports GPG/PGP email signing: +- --sign: Sign email with your GPG key (uses default key from git config) +- --gpg-key : Sign with a specific GPG key +- --list-gpg-keys: List available GPG signing keys + Supports scheduled sending with the --schedule flag. You can specify: - Duration: "30m", "2h", "1d" (minutes, hours, days from now) - Time: "14:30" or "2:30pm" (today or tomorrow if past) @@ -53,6 +62,15 @@ Supports custom metadata: Example: ` # Send immediately nylas email send --to user@example.com --subject "Hello" --body "Hi there!" + # Send with GPG signature (uses default key from git config) + nylas email send --to user@example.com --subject "Secure" --body "Signed email" --sign + + # Send with specific GPG key + nylas email send --to user@example.com --subject "Secure" --body "Signed" --sign --gpg-key 601FEE9B1D60185F + + # List available GPG keys + nylas email send --list-gpg-keys + # Send in 2 hours nylas email send --to user@example.com --subject "Reminder" --schedule 2h @@ -69,6 +87,20 @@ Supports custom metadata: nylas email send --to user@example.com --subject "Invoice" --metadata campaign=q4 --metadata type=invoice`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // Handle --list-gpg-keys early (no client needed) + if listGPGKeys { + return handleListGPGKeys(cmd.Context()) + } + + // Check auto-sign config if --sign flag not explicitly set + if !cmd.Flags().Changed("sign") { + configStore := configAdapter.NewDefaultFileStore() + cfg, err := configStore.Load() + if err == nil && cfg != nil && cfg.GPG != nil && cfg.GPG.AutoSign { + sign = true + } + } + // Interactive mode (runs before WithClient) if interactive || (len(to) == 0 && subject == "" && body == "") { reader := bufio.NewReader(os.Stdin) @@ -201,6 +233,25 @@ Supports custom metadata: if len(metadata) > 0 { fmt.Printf(" %s %s\n", common.Cyan.Sprint("Metadata:"), strings.Join(metadata, ", ")) } + if sign { + var signingInfo string + if gpgKeyID != "" { + // Explicit key ID provided + signingInfo = fmt.Sprintf("key %s", gpgKeyID) + } else { + // Auto-detect from From address + fromEmail := "" + if len(toContacts) > 0 && len(req.From) > 0 { + fromEmail = req.From[0].Email + } + if fromEmail != "" { + signingInfo = fmt.Sprintf("as %s", fromEmail) + } else { + signingInfo = "default key from git config" + } + } + fmt.Printf(" %s %s\n", common.Green.Sprint("GPG Signed:"), signingInfo) + } if !noConfirm { if scheduledTime.IsZero() { @@ -220,19 +271,36 @@ Supports custom metadata: // Send _, sendErr := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { - // Show progress spinner while sending - var sendMsg string - if scheduledTime.IsZero() { - sendMsg = "Sending email..." + var msg *domain.Message + var err error + + if sign { + // Get grant info to determine From email address + grant, grantErr := client.GetGrant(ctx, grantID) + if grantErr == nil && grant != nil && grant.Email != "" { + // Populate From field with grant's email address + req.From = []domain.EmailParticipant{ + {Email: grant.Email}, + } + } + + // GPG signing flow + msg, err = sendSignedEmail(ctx, client, grantID, req, gpgKeyID, toContacts, subject, body) } else { - sendMsg = "Scheduling email..." - } + // Standard flow + var sendMsg string + if scheduledTime.IsZero() { + sendMsg = "Sending email..." + } else { + sendMsg = "Scheduling email..." + } - spinner := common.NewSpinner(sendMsg) - spinner.Start() + spinner := common.NewSpinner(sendMsg) + spinner.Start() - msg, err := client.SendMessage(ctx, grantID, req) - spinner.Stop() + msg, err = client.SendMessage(ctx, grantID, req) + spinner.Stop() + } if err != nil { return struct{}{}, common.WrapSendError("email", err) @@ -246,7 +314,11 @@ Supports custom metadata: printSuccess("Email scheduled successfully! Message ID: %s", msg.ID) fmt.Printf("Scheduled to send: %s\n", scheduledTime.Format(common.DisplayWeekdayFullWithTZ)) } else { - printSuccess("Email sent successfully! Message ID: %s", msg.ID) + if sign { + printSuccess("Signed email sent successfully! Message ID: %s", msg.ID) + } else { + printSuccess("Email sent successfully! Message ID: %s", msg.ID) + } } return struct{}{}, nil }) @@ -268,6 +340,9 @@ Supports custom metadata: cmd.Flags().StringVar(&trackLabel, "track-label", "", "Label for tracking (used to group tracked emails)") cmd.Flags().StringSliceVar(&metadata, "metadata", nil, "Custom metadata as key=value (can be repeated)") cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + cmd.Flags().BoolVar(&sign, "sign", false, "Sign email with GPG (uses default key from git config)") + cmd.Flags().StringVar(&gpgKeyID, "gpg-key", "", "Specific GPG key ID to use for signing") + cmd.Flags().BoolVar(&listGPGKeys, "list-gpg-keys", false, "List available GPG signing keys and exit") return cmd } diff --git a/internal/cli/email/send_gpg.go b/internal/cli/email/send_gpg.go new file mode 100644 index 0000000..f98d617 --- /dev/null +++ b/internal/cli/email/send_gpg.go @@ -0,0 +1,204 @@ +package email + +import ( + "context" + "fmt" + "strings" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/gpg" + "github.com/nylas/cli/internal/adapters/mime" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// handleListGPGKeys lists available GPG signing keys. +func handleListGPGKeys(ctx context.Context) error { + gpgSvc := gpg.NewService() + + // Check GPG is available + if err := gpgSvc.CheckGPGAvailable(ctx); err != nil { + return err + } + + // List keys + keys, err := gpgSvc.ListSigningKeys(ctx) + if err != nil { + return err + } + + if len(keys) == 0 { + fmt.Println("No GPG signing keys found.") + fmt.Println("\nTo generate a new GPG key, run: gpg --gen-key") + return nil + } + + fmt.Printf("Available GPG signing keys (%d):\n\n", len(keys)) + for i, key := range keys { + fmt.Printf("%d. Key ID: %s\n", i+1, key.KeyID) + if key.Fingerprint != "" { + fmt.Printf(" Fingerprint: %s\n", key.Fingerprint) + } + for _, uid := range key.UIDs { + fmt.Printf(" UID: %s\n", uid) + } + if key.Expires != nil { + fmt.Printf(" Expires: %s\n", key.Expires.Format("2006-01-02")) + } else { + fmt.Printf(" Expires: Never\n") + } + fmt.Println() + } + + // Show default keys if configured + fmt.Println("Default signing keys:") + + // Check Nylas config + configStore := config.NewDefaultFileStore() + cfg, err := configStore.Load() + if err == nil && cfg != nil && cfg.GPG != nil && cfg.GPG.DefaultKey != "" { + fmt.Printf(" From Nylas config: %s\n", cfg.GPG.DefaultKey) + } + + // Check git config + defaultKey, err := gpgSvc.GetDefaultSigningKey(ctx) + if err == nil && defaultKey != nil { + fmt.Printf(" From git config: %s\n", defaultKey.KeyID) + } + + return nil +} + +// sendSignedEmail signs an email with GPG and sends it as raw MIME. +func sendSignedEmail(ctx context.Context, client ports.NylasClient, grantID string, req *domain.SendMessageRequest, gpgKeyID string, toContacts []domain.EmailParticipant, subject, body string) (*domain.Message, error) { + gpgSvc := gpg.NewService() + + // Step 1: Check GPG is available + spinner := common.NewSpinner("Checking GPG...") + spinner.Start() + if err := gpgSvc.CheckGPGAvailable(ctx); err != nil { + spinner.Stop() + return nil, err + } + spinner.Stop() + + // Step 2: Get signing key/identity (Priority: CLI flag > Config > From email > Git config) + spinner = common.NewSpinner("Getting GPG signing key...") + spinner.Start() + var keyID string + var signingIdentity string + + if gpgKeyID != "" { + // Priority 1: Explicit key ID provided via --gpg-key flag + keyID = gpgKeyID + signingIdentity = gpgKeyID + } else { + // Priority 2: Check Nylas config for default key + configStore := config.NewDefaultFileStore() + cfg, err := configStore.Load() + if err == nil && cfg != nil && cfg.GPG != nil && cfg.GPG.DefaultKey != "" { + keyID = cfg.GPG.DefaultKey + signingIdentity = keyID + } else if len(req.From) > 0 && req.From[0].Email != "" { + // Priority 3: Use From email address to find key + // IMPORTANT: We must use the actual key ID for --local-user, not the email. + // GPG's --sender option only works correctly when --local-user is a key ID. + fromEmail := req.From[0].Email + signingIdentity = fromEmail + + // Look up the actual key ID for this email + key, err := gpgSvc.FindKeyByEmail(ctx, fromEmail) + if err != nil { + spinner.Stop() + return nil, fmt.Errorf("no GPG key found for %s: %w", fromEmail, err) + } + keyID = key.KeyID + } else { + // Priority 4: Fallback to default key from git config + key, err := gpgSvc.GetDefaultSigningKey(ctx) + if err != nil { + spinner.Stop() + return nil, err + } + keyID = key.KeyID + signingIdentity = keyID + } + } + spinner.Stop() + + // Step 3: Build MIME content to sign + spinner = common.NewSpinner(fmt.Sprintf("Signing email with GPG identity: %s...", signingIdentity)) + spinner.Start() + + // Determine content type + contentType := "text/plain" + if strings.Contains(strings.ToLower(body), " 0 && req.From[0].Email != "" { + senderEmail = req.From[0].Email + } + + // Sign the MIME content part with sender email for proper UID embedding + signResult, err := gpgSvc.SignData(ctx, keyID, dataToSign, senderEmail) + if err != nil { + spinner.Stop() + return nil, err + } + spinner.Stop() + + // Step 4: Build PGP/MIME message + spinner = common.NewSpinner("Building PGP/MIME message...") + spinner.Start() + + // Use the same MIME builder instance to ensure consistency + mimeReq := &mime.SignedMessageRequest{ + From: req.From, + To: toContacts, + Cc: req.Cc, + Bcc: req.Bcc, + ReplyTo: req.ReplyTo, + Subject: subject, + Body: body, + ContentType: contentType, + Signature: signResult.Signature, + HashAlgo: signResult.HashAlgo, + PreparedContent: dataToSign, // Use the exact content that was signed + Attachments: req.Attachments, + } + + rawMIME, err := mimeBuilder.BuildSignedMessage(mimeReq) + if err != nil { + spinner.Stop() + return nil, fmt.Errorf("failed to build PGP/MIME message: %w", err) + } + spinner.Stop() + + // Step 5: Send raw MIME message + spinner = common.NewSpinner("Sending signed email...") + spinner.Start() + + msg, err := client.SendRawMessage(ctx, grantID, rawMIME) + spinner.Stop() + + if err != nil { + return nil, err + } + + return msg, nil +} diff --git a/internal/cli/email/send_gpg_test.go b/internal/cli/email/send_gpg_test.go new file mode 100644 index 0000000..dc08134 --- /dev/null +++ b/internal/cli/email/send_gpg_test.go @@ -0,0 +1,179 @@ +package email + +import ( + "context" + "strings" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" +) + +func TestHandleListGPGKeys_NoGPG(t *testing.T) { + // This test will skip if GPG is not installed + ctx := context.Background() + err := handleListGPGKeys(ctx) + + // Either succeeds or fails with "GPG not found" error + if err != nil && !strings.Contains(err.Error(), "GPG not found") { + t.Errorf("handleListGPGKeys() unexpected error = %v", err) + } +} + +func TestSendSignedEmail_MockClient(t *testing.T) { + if testing.Short() { + t.Skip("Skipping GPG integration test in short mode") + } + + ctx := context.Background() + + // Create mock client + mockClient := &nylas.MockClient{ + SendRawMessageFunc: func(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error) { + // Validate raw MIME contains expected markers + mimeStr := string(rawMIME) + if !strings.Contains(mimeStr, "MIME-Version") { + t.Error("Raw MIME missing MIME-Version header") + } + if !strings.Contains(mimeStr, "multipart/signed") { + t.Error("Raw MIME missing multipart/signed content type") + } + if !strings.Contains(mimeStr, "application/pgp-signature") { + t.Error("Raw MIME missing PGP signature content type") + } + + return &domain.Message{ + ID: "test-msg-id", + GrantID: grantID, + }, nil + }, + } + + req := &domain.SendMessageRequest{ + Subject: "Test Subject", + Body: "Test body", + To: []domain.EmailParticipant{ + {Email: "test@example.com"}, + }, + } + + toContacts := []domain.EmailParticipant{ + {Email: "test@example.com"}, + } + + // This will fail if GPG is not configured, which is expected + msg, err := sendSignedEmail(ctx, mockClient, "test-grant", req, "", toContacts, "Test Subject", "Test body") + + // If GPG is not available, we expect an error + if err != nil { + // Expected errors: + // - "GPG not found" (no GPG installed) + // - "no default GPG key" (GPG installed but no keys/config) + if !strings.Contains(err.Error(), "GPG") && !strings.Contains(err.Error(), "gpg") { + t.Errorf("sendSignedEmail() unexpected error = %v", err) + } + t.Skipf("GPG not configured, skipping test: %v", err) + return + } + + // If we got here, GPG is configured and signing worked + if msg == nil { + t.Fatal("sendSignedEmail() returned nil message") + } + if msg.ID != "test-msg-id" { + t.Errorf("sendSignedEmail() message ID = %v, want test-msg-id", msg.ID) + } +} + +func TestSendSignedEmail_WithSpecificKey(t *testing.T) { + if testing.Short() { + t.Skip("Skipping GPG integration test in short mode") + } + + ctx := context.Background() + + // Create mock client + mockClient := &nylas.MockClient{ + SendRawMessageFunc: func(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error) { + return &domain.Message{ + ID: "test-msg-id-2", + GrantID: grantID, + }, nil + }, + } + + req := &domain.SendMessageRequest{ + Subject: "Test", + Body: "Body", + To: []domain.EmailParticipant{ + {Email: "test@example.com"}, + }, + } + + toContacts := []domain.EmailParticipant{ + {Email: "test@example.com"}, + } + + // Try with an invalid key ID - should fail + _, err := sendSignedEmail(ctx, mockClient, "test-grant", req, "INVALID_KEY_ID", toContacts, "Test", "Body") + + // Should fail with GPG error + if err == nil { + t.Error("sendSignedEmail() with invalid key should fail") + } + + // Error should mention the key or GPG + if !strings.Contains(err.Error(), "GPG") && !strings.Contains(err.Error(), "key") { + t.Errorf("sendSignedEmail() error should mention GPG or key, got: %v", err) + } +} + +func TestSendSignedEmail_HTMLBody(t *testing.T) { + if testing.Short() { + t.Skip("Skipping GPG integration test in short mode") + } + + ctx := context.Background() + + // Create mock client that validates HTML content type + mockClient := &nylas.MockClient{ + SendRawMessageFunc: func(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error) { + mimeStr := string(rawMIME) + if !strings.Contains(mimeStr, "text/html") { + t.Error("Expected text/html content type for HTML body") + } + return &domain.Message{ + ID: "test-html-msg", + GrantID: grantID, + }, nil + }, + } + + req := &domain.SendMessageRequest{ + Subject: "HTML Test", + Body: "

Hello

", + To: []domain.EmailParticipant{ + {Email: "test@example.com"}, + }, + } + + toContacts := []domain.EmailParticipant{ + {Email: "test@example.com"}, + } + + // This will skip if GPG is not configured + msg, err := sendSignedEmail(ctx, mockClient, "test-grant", req, "", toContacts, "HTML Test", "

Hello

") + + if err != nil { + if strings.Contains(err.Error(), "GPG") || strings.Contains(err.Error(), "gpg") { + t.Skipf("GPG not configured, skipping test: %v", err) + return + } + t.Errorf("sendSignedEmail() error = %v", err) + return + } + + if msg == nil { + t.Fatal("sendSignedEmail() returned nil message") + } +} diff --git a/internal/cli/email/send_test.go b/internal/cli/email/send_test.go new file mode 100644 index 0000000..bb6f553 --- /dev/null +++ b/internal/cli/email/send_test.go @@ -0,0 +1,453 @@ +package email + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/domain" + "github.com/spf13/cobra" +) + +func TestSendCmd_GPGFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantSign bool + wantKey string + }{ + { + name: "no sign flag", + args: []string{"--to", "test@example.com", "--subject", "Test", "--body", "Body"}, + wantSign: false, + wantKey: "", + }, + { + name: "sign flag set", + args: []string{"--to", "test@example.com", "--subject", "Test", "--body", "Body", "--sign"}, + wantSign: true, + wantKey: "", + }, + { + name: "sign flag with specific key", + args: []string{"--to", "test@example.com", "--subject", "Test", "--body", "Body", "--sign", "--gpg-key", "ABC123"}, + wantSign: true, + wantKey: "ABC123", + }, + { + name: "gpg-key without sign flag", + args: []string{"--to", "test@example.com", "--subject", "Test", "--body", "Body", "--gpg-key", "ABC123"}, + wantSign: false, + wantKey: "ABC123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newSendCmd() + + // Add no-confirm flag to skip prompts + tt.args = append(tt.args, "--yes") + + // Set args + cmd.SetArgs(tt.args) + + // Parse flags + if err := cmd.ParseFlags(tt.args); err != nil { + t.Fatalf("Failed to parse flags: %v", err) + } + + // Get flag values + signFlag, err := cmd.Flags().GetBool("sign") + if err != nil { + t.Fatalf("Failed to get sign flag: %v", err) + } + + gpgKeyFlag, err := cmd.Flags().GetString("gpg-key") + if err != nil { + t.Fatalf("Failed to get gpg-key flag: %v", err) + } + + // Validate + if signFlag != tt.wantSign { + t.Errorf("sign flag = %v, want %v", signFlag, tt.wantSign) + } + if gpgKeyFlag != tt.wantKey { + t.Errorf("gpg-key flag = %v, want %v", gpgKeyFlag, tt.wantKey) + } + }) + } +} + +func TestSendCmd_ListGPGKeysFlag(t *testing.T) { + cmd := newSendCmd() + + args := []string{"--list-gpg-keys"} + cmd.SetArgs(args) + + // Parse flags + if err := cmd.ParseFlags(args); err != nil { + t.Fatalf("Failed to parse flags: %v", err) + } + + listGPGKeysFlag, err := cmd.Flags().GetBool("list-gpg-keys") + if err != nil { + t.Fatalf("Failed to get list-gpg-keys flag: %v", err) + } + + if !listGPGKeysFlag { + t.Error("Expected list-gpg-keys flag to be true") + } +} + +func TestSendCmd_AutoSignConfig(t *testing.T) { + // Create temp config directory + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + // Set up config with auto-sign enabled + store := config.NewFileStore(configPath) + cfg := domain.DefaultConfig() + cfg.GPG = &domain.GPGConfig{ + AutoSign: true, + } + if err := store.Save(cfg); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Override config path for this test + originalConfigPath := os.Getenv("NYLAS_CONFIG_PATH") + _ = os.Setenv("NYLAS_CONFIG_PATH", configPath) + defer func() { + if originalConfigPath != "" { + _ = os.Setenv("NYLAS_CONFIG_PATH", originalConfigPath) + } else { + _ = os.Unsetenv("NYLAS_CONFIG_PATH") + } + }() + + // Note: The actual auto-sign loading happens in RunE, which we can't easily test + // without mocking the entire client and network stack. This test validates + // that the config can be loaded and has the expected structure. + + loadedCfg, err := store.Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if loadedCfg.GPG == nil { + t.Fatal("GPG config is nil") + } + if !loadedCfg.GPG.AutoSign { + t.Error("Expected auto_sign to be true") + } +} + +func TestSendCmd_FlagDefinitions(t *testing.T) { + cmd := newSendCmd() + + // Test that all expected flags are defined + expectedFlags := []struct { + name string + shorthand string + flagType string + }{ + {name: "to", shorthand: "", flagType: "stringSlice"}, + {name: "cc", shorthand: "", flagType: "stringSlice"}, + {name: "bcc", shorthand: "", flagType: "stringSlice"}, + {name: "subject", shorthand: "s", flagType: "string"}, + {name: "body", shorthand: "b", flagType: "string"}, + {name: "sign", shorthand: "", flagType: "bool"}, + {name: "gpg-key", shorthand: "", flagType: "string"}, + {name: "list-gpg-keys", shorthand: "", flagType: "bool"}, + {name: "interactive", shorthand: "i", flagType: "bool"}, + {name: "yes", shorthand: "y", flagType: "bool"}, + } + + for _, expected := range expectedFlags { + flag := cmd.Flags().Lookup(expected.name) + if flag == nil { + t.Errorf("Flag %s not found", expected.name) + continue + } + + if expected.shorthand != "" && flag.Shorthand != expected.shorthand { + t.Errorf("Flag %s shorthand = %s, want %s", expected.name, flag.Shorthand, expected.shorthand) + } + + // Validate flag type + switch expected.flagType { + case "bool": + if flag.Value.Type() != "bool" { + t.Errorf("Flag %s type = %s, want bool", expected.name, flag.Value.Type()) + } + case "string": + if flag.Value.Type() != "string" { + t.Errorf("Flag %s type = %s, want string", expected.name, flag.Value.Type()) + } + case "stringSlice": + if flag.Value.Type() != "stringSlice" { + t.Errorf("Flag %s type = %s, want stringSlice", expected.name, flag.Value.Type()) + } + } + } +} + +func TestSendCmd_UsageText(t *testing.T) { + cmd := newSendCmd() + + // Validate that usage includes GPG information + usage := cmd.Long + if !strings.Contains(usage, "GPG") && !strings.Contains(usage, "PGP") { + t.Error("Usage text should mention GPG/PGP signing") + } + if !strings.Contains(usage, "--sign") { + t.Error("Usage text should mention --sign flag") + } + if !strings.Contains(usage, "--gpg-key") { + t.Error("Usage text should mention --gpg-key flag") + } + if !strings.Contains(usage, "--list-gpg-keys") { + t.Error("Usage text should mention --list-gpg-keys flag") + } + + // Validate examples include GPG + example := cmd.Example + if !strings.Contains(example, "gpg") && !strings.Contains(example, "sign") { + t.Error("Examples should include GPG signing usage") + } +} + +func TestSendCmd_CommandStructure(t *testing.T) { + cmd := newSendCmd() + + // Validate basic command structure + if cmd.Use != "send [grant-id]" { + t.Errorf("Use = %s, want 'send [grant-id]'", cmd.Use) + } + + if cmd.Short == "" { + t.Error("Short description should not be empty") + } + + if cmd.Long == "" { + t.Error("Long description should not be empty") + } + + if cmd.Example == "" { + t.Error("Examples should not be empty") + } + + // Validate RunE is set + if cmd.RunE == nil { + t.Error("RunE function should be set") + } + + // Validate max args + if cmd.Args == nil { + t.Error("Args validator should be set") + } +} + +func TestSendCmd_GPGKeyFlagValidation(t *testing.T) { + tests := []struct { + name string + keyID string + wantErr bool + }{ + { + name: "valid key ID", + keyID: "601FEE9B1D60185F", + wantErr: false, + }, + { + name: "short key ID", + keyID: "1D60185F", + wantErr: false, + }, + { + name: "full fingerprint", + keyID: "1234567890ABCDEF1234567890ABCDEF12345678", + wantErr: false, + }, + { + name: "email as identifier", + keyID: "user@example.com", + wantErr: false, + }, + { + name: "empty key ID", + keyID: "", + wantErr: false, // Empty is valid (uses default) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newSendCmd() + + args := []string{ + "--to", "test@example.com", + "--subject", "Test", + "--body", "Body", + "--sign", + } + + if tt.keyID != "" { + args = append(args, "--gpg-key", tt.keyID) + } + + cmd.SetArgs(args) + + // Parse flags (should not error on valid key IDs) + err := cmd.ParseFlags(args) + if (err != nil) != tt.wantErr { + t.Errorf("ParseFlags() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + gpgKey, _ := cmd.Flags().GetString("gpg-key") + if tt.keyID != "" && gpgKey != tt.keyID { + t.Errorf("gpg-key = %s, want %s", gpgKey, tt.keyID) + } + } + }) + } +} + +// TestSendCmd_Integration tests the command in a more integrated way +// This test validates that flags work together correctly +func TestSendCmd_Integration(t *testing.T) { + tests := []struct { + name string + args []string + test func(t *testing.T, cmd *cobra.Command) + }{ + { + name: "basic send with GPG signing", + args: []string{ + "--to", "recipient@example.com", + "--subject", "Secure Message", + "--body", "This is a signed email", + "--sign", + "--yes", + }, + test: func(t *testing.T, cmd *cobra.Command) { + sign, _ := cmd.Flags().GetBool("sign") + if !sign { + t.Error("Expected sign flag to be true") + } + + to, _ := cmd.Flags().GetStringSlice("to") + if len(to) != 1 || to[0] != "recipient@example.com" { + t.Errorf("to = %v, want [recipient@example.com]", to) + } + }, + }, + { + name: "send with specific GPG key", + args: []string{ + "--to", "recipient@example.com", + "--subject", "Test", + "--body", "Body", + "--sign", + "--gpg-key", "ABC123", + "--yes", + }, + test: func(t *testing.T, cmd *cobra.Command) { + sign, _ := cmd.Flags().GetBool("sign") + gpgKey, _ := cmd.Flags().GetString("gpg-key") + + if !sign { + t.Error("Expected sign flag to be true") + } + if gpgKey != "ABC123" { + t.Errorf("gpg-key = %s, want ABC123", gpgKey) + } + }, + }, + { + name: "send with CC and BCC while signing", + args: []string{ + "--to", "to@example.com", + "--cc", "cc@example.com", + "--bcc", "bcc@example.com", + "--subject", "Multi-recipient", + "--body", "Test", + "--sign", + "--yes", + }, + test: func(t *testing.T, cmd *cobra.Command) { + sign, _ := cmd.Flags().GetBool("sign") + to, _ := cmd.Flags().GetStringSlice("to") + cc, _ := cmd.Flags().GetStringSlice("cc") + bcc, _ := cmd.Flags().GetStringSlice("bcc") + + if !sign { + t.Error("Expected sign flag to be true") + } + if len(to) != 1 { + t.Errorf("to length = %d, want 1", len(to)) + } + if len(cc) != 1 { + t.Errorf("cc length = %d, want 1", len(cc)) + } + if len(bcc) != 1 { + t.Errorf("bcc length = %d, want 1", len(bcc)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newSendCmd() + cmd.SetArgs(tt.args) + + // Parse flags + if err := cmd.ParseFlags(tt.args); err != nil { + t.Fatalf("Failed to parse flags: %v", err) + } + + // Run test-specific validation + tt.test(t, cmd) + }) + } +} + +// TestSendCmd_OutputCapture tests that the command can capture output +func TestSendCmd_OutputCapture(t *testing.T) { + cmd := newSendCmd() + + // Redirect stdout/stderr + oldStdout := os.Stdout + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stdout = w + os.Stderr = w + + defer func() { + os.Stdout = oldStdout + os.Stderr = oldStderr + }() + + // Set help flag to get help output + cmd.SetArgs([]string{"--help"}) + + // Execute (should show help and exit) + _ = cmd.Execute() + + // Close write end and read captured output + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + output := buf.String() + + // Validate help output includes GPG information + if !strings.Contains(output, "gpg") && !strings.Contains(output, "GPG") { + t.Error("Help output should mention GPG") + } +} diff --git a/internal/cli/integration/email_gpg_test.go b/internal/cli/integration/email_gpg_test.go new file mode 100644 index 0000000..378a08a --- /dev/null +++ b/internal/cli/integration/email_gpg_test.go @@ -0,0 +1,363 @@ +//go:build integration +// +build integration + +package integration + +import ( + "strings" + "testing" + "time" +) + +// ============================================================================= +// GPG EMAIL SIGNING AND VERIFICATION TESTS +// ============================================================================= + +func TestCLI_EmailSend_GPGSigned(t *testing.T) { + skipIfMissingCreds(t) + + email := getTestEmail() + if email == "" { + t.Skip("NYLAS_TEST_EMAIL not set, skipping GPG send test") + } + + acquireRateLimit(t) + + // Send a GPG-signed email to self + stdout, stderr, err := runCLI("email", "send", + "--to", email, + "--subject", "[CLI Test] GPG Signed Email", + "--body", "This is a GPG-signed test email from CLI integration tests.", + "--sign", + "--yes", + testGrantID) + + if err != nil { + // Skip if GPG is not available + if strings.Contains(stderr, "GPG not found") || strings.Contains(stderr, "no GPG key") { + t.Skip("GPG not available or no keys configured, skipping test") + } + t.Fatalf("email send --sign failed: %v\nstderr: %s", err, stderr) + } + + // Should show signed email confirmation + if !strings.Contains(stdout, "Signed email sent") && !strings.Contains(stdout, "Message ID") { + t.Errorf("Expected signed email confirmation, got: %s", stdout) + } + + t.Logf("GPG signed email sent:\n%s", stdout) +} + +func TestCLI_EmailSend_GPGSignedAndVerify(t *testing.T) { + skipIfMissingCreds(t) + + email := getTestEmail() + if email == "" { + t.Skip("NYLAS_TEST_EMAIL not set, skipping GPG verify test") + } + + acquireRateLimit(t) + + // Step 1: Send a GPG-signed email to self + sendStdout, sendStderr, err := runCLI("email", "send", + "--to", email, + "--subject", "[CLI Test] GPG Verify Test "+time.Now().Format("15:04:05"), + "--body", "This email will be verified after sending.", + "--sign", + "--yes", + testGrantID) + + if err != nil { + if strings.Contains(sendStderr, "GPG not found") || strings.Contains(sendStderr, "no GPG key") { + t.Skip("GPG not available or no keys configured, skipping test") + } + t.Fatalf("email send --sign failed: %v\nstderr: %s", err, sendStderr) + } + + // Extract message ID from output + messageID := extractMessageID(sendStdout) + if messageID == "" { + t.Fatalf("Could not extract message ID from output: %s", sendStdout) + } + + t.Logf("Sent signed email with ID: %s", messageID) + + // Wait for email to be delivered + time.Sleep(3 * time.Second) + acquireRateLimit(t) + + // Step 2: Verify the signature + verifyStdout, verifyStderr, err := runCLI("email", "read", messageID, "--verify", testGrantID) + + if err != nil { + t.Fatalf("email read --verify failed: %v\nstderr: %s", err, verifyStderr) + } + + // Should show "Good signature" + if !strings.Contains(verifyStdout, "Good signature") { + t.Errorf("Expected 'Good signature' in output, got: %s", verifyStdout) + } + + // Should show signer info + if !strings.Contains(verifyStdout, "Signer:") { + t.Errorf("Expected 'Signer:' in verification output, got: %s", verifyStdout) + } + + if !strings.Contains(verifyStdout, "Key ID:") { + t.Errorf("Expected 'Key ID:' in verification output, got: %s", verifyStdout) + } + + t.Logf("GPG verification output:\n%s", verifyStdout) + + // Cleanup: delete the test email + acquireRateLimit(t) + _, _, _ = runCLI("email", "delete", messageID, "--yes", testGrantID) +} + +func TestCLI_EmailRead_VerifyBehavior(t *testing.T) { + skipIfMissingCreds(t) + + email := getTestEmail() + if email == "" { + t.Skip("NYLAS_TEST_EMAIL not set, skipping verify behavior test") + } + + acquireRateLimit(t) + + // Send an email without explicit --sign flag + sendStdout, sendStderr, err := runCLI("email", "send", + "--to", email, + "--subject", "[CLI Test] Verify Behavior "+time.Now().Format("15:04:05"), + "--body", "Testing verification behavior.", + "--yes", + testGrantID) + + if err != nil { + t.Fatalf("email send failed: %v\nstderr: %s", err, sendStderr) + } + + messageID := extractMessageID(sendStdout) + if messageID == "" { + t.Fatalf("Could not extract message ID from output: %s", sendStdout) + } + + // Wait for email to be delivered + time.Sleep(3 * time.Second) + acquireRateLimit(t) + + // Try to verify the email + verifyStdout, verifyStderr, err := runCLI("email", "read", messageID, "--verify", testGrantID) + + // Log the verification result regardless of outcome + combined := verifyStdout + verifyStderr + if err != nil { + // Verification failed - expected for truly unsigned emails + if strings.Contains(combined, "not PGP/MIME signed") || strings.Contains(combined, "not signed") { + t.Log("Unsigned email correctly identified as not signed") + } else { + t.Logf("Verification error: %s", verifyStderr) + } + } else { + // Verification succeeded + if strings.Contains(combined, "Good signature") { + t.Log("Email has valid signature (may be auto-signed by mail server)") + } else if strings.Contains(combined, "BAD signature") { + t.Log("Email has invalid/tampered signature") + } else { + t.Logf("Verify output: %s", verifyStdout) + } + } + + // Cleanup + acquireRateLimit(t) + _, _, _ = runCLI("email", "delete", messageID, "--yes", testGrantID) +} + +// ============================================================================= +// RAW MIME RETRIEVAL TESTS +// ============================================================================= + +func TestCLI_EmailRead_RawMIME(t *testing.T) { + skipIfMissingCreds(t) + + email := getTestEmail() + if email == "" { + t.Skip("NYLAS_TEST_EMAIL not set, skipping raw MIME test") + } + + acquireRateLimit(t) + + // Send a test email first + sendStdout, sendStderr, err := runCLI("email", "send", + "--to", email, + "--subject", "[CLI Test] MIME Test "+time.Now().Format("15:04:05"), + "--body", "Testing raw MIME retrieval.", + "--yes", + testGrantID) + + if err != nil { + t.Fatalf("email send failed: %v\nstderr: %s", err, sendStderr) + } + + messageID := extractMessageID(sendStdout) + if messageID == "" { + t.Fatalf("Could not extract message ID from output: %s", sendStdout) + } + + // Wait for email to be delivered + time.Sleep(3 * time.Second) + acquireRateLimit(t) + + // Read with --mime flag + mimeStdout, mimeStderr, err := runCLI("email", "read", messageID, "--mime", testGrantID) + + if err != nil { + t.Fatalf("email read --mime failed: %v\nstderr: %s", err, mimeStderr) + } + + // Should contain MIME headers + if !strings.Contains(mimeStdout, "Content-Type:") { + t.Errorf("Expected 'Content-Type:' in MIME output, got: %s", mimeStdout) + } + + if !strings.Contains(mimeStdout, "MIME-Version:") && !strings.Contains(mimeStdout, "RFC822") { + t.Errorf("Expected MIME headers in output, got: %s", mimeStdout) + } + + t.Logf("Raw MIME output (first 500 chars):\n%s", truncate(mimeStdout, 500)) + + // Cleanup + acquireRateLimit(t) + _, _, _ = runCLI("email", "delete", messageID, "--yes", testGrantID) +} + +func TestCLI_EmailRead_SignedMIME(t *testing.T) { + skipIfMissingCreds(t) + + email := getTestEmail() + if email == "" { + t.Skip("NYLAS_TEST_EMAIL not set, skipping signed MIME test") + } + + acquireRateLimit(t) + + // Send a GPG-signed email + sendStdout, sendStderr, err := runCLI("email", "send", + "--to", email, + "--subject", "[CLI Test] Signed MIME Test "+time.Now().Format("15:04:05"), + "--body", "Testing signed email MIME structure.", + "--sign", + "--yes", + testGrantID) + + if err != nil { + if strings.Contains(sendStderr, "GPG not found") || strings.Contains(sendStderr, "no GPG key") { + t.Skip("GPG not available or no keys configured, skipping test") + } + t.Fatalf("email send --sign failed: %v\nstderr: %s", err, sendStderr) + } + + messageID := extractMessageID(sendStdout) + if messageID == "" { + t.Fatalf("Could not extract message ID from output: %s", sendStdout) + } + + // Wait for email to be delivered + time.Sleep(3 * time.Second) + acquireRateLimit(t) + + // Read with --mime flag + mimeStdout, mimeStderr, err := runCLI("email", "read", messageID, "--mime", testGrantID) + + if err != nil { + t.Fatalf("email read --mime failed: %v\nstderr: %s", err, mimeStderr) + } + + // Should be multipart/signed + if !strings.Contains(mimeStdout, "multipart/signed") { + t.Errorf("Expected 'multipart/signed' in MIME output, got: %s", truncate(mimeStdout, 500)) + } + + // Should contain PGP signature + if !strings.Contains(mimeStdout, "application/pgp-signature") { + t.Errorf("Expected 'application/pgp-signature' in MIME output, got: %s", truncate(mimeStdout, 500)) + } + + // Should contain BEGIN PGP SIGNATURE + if !strings.Contains(mimeStdout, "BEGIN PGP SIGNATURE") { + t.Errorf("Expected 'BEGIN PGP SIGNATURE' in MIME output, got: %s", truncate(mimeStdout, 500)) + } + + t.Logf("Signed MIME structure verified") + + // Cleanup + acquireRateLimit(t) + _, _, _ = runCLI("email", "delete", messageID, "--yes", testGrantID) +} + +// ============================================================================= +// GPG KEY LISTING TEST +// ============================================================================= + +func TestCLI_EmailSend_ListGPGKeys(t *testing.T) { + skipIfMissingCreds(t) + + stdout, stderr, err := runCLI("email", "send", "--list-gpg-keys") + + if err != nil { + if strings.Contains(stderr, "GPG not found") { + t.Skip("GPG not installed, skipping test") + } + // No keys is also acceptable + if strings.Contains(stderr, "No GPG signing keys") || strings.Contains(stdout, "No GPG signing keys") { + t.Log("No GPG keys found (this is OK for test environments)") + return + } + t.Fatalf("email send --gpg-list-keys failed: %v\nstderr: %s", err, stderr) + } + + // Should show key info or "no keys" message + if !strings.Contains(stdout, "Key ID:") && !strings.Contains(stdout, "No GPG") && !strings.Contains(stdout, "signing keys") { + t.Errorf("Expected GPG key listing output, got: %s", stdout) + } + + t.Logf("GPG key listing:\n%s", stdout) +} + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +// extractMessageID extracts the message ID from send command output +func extractMessageID(output string) string { + // Look for "Message ID: " pattern + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "Message ID:") { + parts := strings.Split(line, "Message ID:") + if len(parts) > 1 { + return strings.TrimSpace(parts[1]) + } + } + // Also try "ID: " pattern + if strings.Contains(line, "ID:") && !strings.Contains(line, "Key ID:") { + parts := strings.Split(line, "ID:") + if len(parts) > 1 { + id := strings.TrimSpace(parts[1]) + // Filter out short IDs that might be key IDs + if len(id) > 20 { + return id + } + } + } + } + return "" +} + +// truncate truncates a string to maxLen characters +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/internal/domain/config.go b/internal/domain/config.go index 251b327..ad44de1 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -59,6 +59,9 @@ type Config struct { // AI settings AI *AIConfig `yaml:"ai,omitempty"` + + // GPG settings + GPG *GPGConfig `yaml:"gpg,omitempty"` } // APIConfig represents API-specific configuration. @@ -188,3 +191,9 @@ func DefaultWorkingHours() *DaySchedule { End: "17:00", } } + +// GPGConfig represents GPG/PGP email signing configuration. +type GPGConfig struct { + DefaultKey string `yaml:"default_key,omitempty"` // Default GPG key ID for signing + AutoSign bool `yaml:"auto_sign,omitempty"` // Automatically sign all outgoing emails +} diff --git a/internal/domain/email.go b/internal/domain/email.go index b9f0a00..436c959 100644 --- a/internal/domain/email.go +++ b/internal/domain/email.go @@ -92,6 +92,10 @@ type SendMessageRequest struct { Attachments []Attachment `json:"attachments,omitempty"` SendAt int64 `json:"send_at,omitempty"` // Unix timestamp for scheduled sending Metadata map[string]string `json:"metadata,omitempty"` + + // GPG Signature options (not sent to API, used internally for signing) + Sign bool `json:"-"` // Whether to GPG sign this email + GPGKeyID string `json:"-"` // Optional: specific GPG key ID to use } // Validate checks that SendMessageRequest has at least one recipient. diff --git a/internal/ports/messages.go b/internal/ports/messages.go index 650a8cb..88cd4c2 100644 --- a/internal/ports/messages.go +++ b/internal/ports/messages.go @@ -31,6 +31,9 @@ type MessageClient interface { // SendMessage sends a new message. SendMessage(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) + // SendRawMessage sends a raw RFC 822 MIME message. + SendRawMessage(ctx context.Context, grantID string, rawMIME []byte) (*domain.Message, error) + // UpdateMessage updates an existing message. UpdateMessage(ctx context.Context, grantID, messageID string, req *domain.UpdateMessageRequest) (*domain.Message, error) From 8c58ea7365f3e608f7dbac85c1ad5ce5e8adfcdd Mon Sep 17 00:00:00 2001 From: Qasim Date: Sun, 1 Feb 2026 15:22:30 -0500 Subject: [PATCH 2/2] fix(test): handle GPG installed without keys in CI The TestHandleListGPGKeys_NoGPG test now accepts multiple valid error conditions: - GPG not found (not installed) - no GPG secret keys found (installed but no keys) - No GPG signing keys (alternative message) This fixes CI failure where GPG is installed but has no keys configured. --- internal/cli/email/send_gpg_test.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/cli/email/send_gpg_test.go b/internal/cli/email/send_gpg_test.go index dc08134..8ff5c5b 100644 --- a/internal/cli/email/send_gpg_test.go +++ b/internal/cli/email/send_gpg_test.go @@ -10,13 +10,21 @@ import ( ) func TestHandleListGPGKeys_NoGPG(t *testing.T) { - // This test will skip if GPG is not installed + // This test verifies handleListGPGKeys handles various GPG states gracefully ctx := context.Background() err := handleListGPGKeys(ctx) - // Either succeeds or fails with "GPG not found" error - if err != nil && !strings.Contains(err.Error(), "GPG not found") { - t.Errorf("handleListGPGKeys() unexpected error = %v", err) + // Expected outcomes: + // 1. Success (GPG installed with keys) + // 2. "GPG not found" (GPG not installed) + // 3. "no GPG secret keys found" (GPG installed but no keys) + if err != nil { + errStr := err.Error() + if !strings.Contains(errStr, "GPG not found") && + !strings.Contains(errStr, "no GPG secret keys") && + !strings.Contains(errStr, "No GPG signing keys") { + t.Errorf("handleListGPGKeys() unexpected error = %v", err) + } } }