Complete guide to Forward Email API integration patterns, authentication, and service implementation.
Base URL: https://api.forwardemail.net/v1/
Authentication: HTTP Basic Authentication
API Documentation: Limited official docs, reverse-engineered from Auth.js examples
Rate Limiting: Respectful usage required, 10 requests/day limit for logs
For complete API coverage status and endpoint mapping, see API Reference.
Forward Email API uses HTTP Basic authentication with the API key as the username:
// Authentication format
Authorization: Basic base64(api_key + ":")// pkg/api/client.go
func (c *Client) setAuth(req *http.Request) {
if c.authProvider != nil {
apiKey, err := c.authProvider.GetAPIKey()
if err == nil && apiKey != "" {
// HTTP Basic: API key as username, empty password
req.SetBasicAuth(apiKey, "")
}
}
}// pkg/auth/provider.go
type Provider interface {
GetAPIKey() (string, error)
ValidateAPIKey(ctx context.Context, apiKey string) error
GetProfile() string
}
type AuthProvider struct {
config *config.Config
keyring keyring.Keyring
profile string
}-
Environment Variables (highest priority)
FORWARDEMAIL_API_KEY="your-api-key" FORWARDEMAIL_PRODUCTION_API_KEY="prod-key"
-
OS Keyring (recommended)
- macOS: Keychain Services
- Windows: Credential Manager
- Linux: Secret Service
-
Configuration File (fallback)
profiles: production: api_key: "stored-in-config" # Not recommended
-
Interactive Prompt (last resort)
All API services follow a consistent pattern:
// Service interface
type Service interface {
List(ctx context.Context, options ListOptions) (*ListResponse, error)
Get(ctx context.Context, id string) (*Resource, error)
Create(ctx context.Context, req CreateRequest) (*Resource, error)
Update(ctx context.Context, id string, req UpdateRequest) (*Resource, error)
Delete(ctx context.Context, id string) error
}
// Service implementation
type DomainService struct {
client *Client
}
func (s *DomainService) List(ctx context.Context, options DomainListOptions) (*DomainListResponse, error) {
// Build request with pagination, filtering
req, err := s.buildListRequest(options)
if err != nil {
return nil, fmt.Errorf("failed to build request: %w", err)
}
// Execute request with error handling
resp, err := s.client.Do(ctx, req)
if err != nil {
return nil, fmt.Errorf("API request failed: %w", err)
}
// Parse and validate response
var result DomainListResponse
if err := s.client.parseResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}// pkg/api/client.go
type Client struct {
baseURL string
httpClient *http.Client
authProvider auth.Provider
userAgent string
}
func (c *Client) Do(ctx context.Context, req *http.Request) (*http.Response, error) {
// Set authentication
c.setAuth(req)
// Set standard headers
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json")
if req.Method != http.MethodGet && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
// Execute with context
resp, err := c.httpClient.Do(req.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
// Handle API errors
if err := c.checkResponse(resp); err != nil {
resp.Body.Close()
return nil, err
}
return resp, nil
}// pkg/api/domain_service.go
type DomainService struct {
client *Client
}
// List domains with filtering and pagination
func (s *DomainService) List(ctx context.Context, options DomainListOptions) (*DomainListResponse, error) {
url := "/domains"
if len(options.toQuery()) > 0 {
url += "?" + options.toQuery().Encode()
}
req, err := s.client.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(ctx, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result DomainListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// Get single domain
func (s *DomainService) Get(ctx context.Context, domainID string) (*Domain, error) {
req, err := s.client.NewRequest(http.MethodGet, "/domains/"+domainID, nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(ctx, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var domain Domain
if err := json.NewDecoder(resp.Body).Decode(&domain); err != nil {
return nil, fmt.Errorf("failed to decode domain: %w", err)
}
return &domain, nil
}
// Create new domain
func (s *DomainService) Create(ctx context.Context, req DomainCreateRequest) (*Domain, error) {
httpReq, err := s.client.NewRequest(http.MethodPost, "/domains", req)
if err != nil {
return nil, err
}
resp, err := s.client.Do(ctx, httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var domain Domain
if err := json.NewDecoder(resp.Body).Decode(&domain); err != nil {
return nil, fmt.Errorf("failed to decode created domain: %w", err)
}
return &domain, nil
}// pkg/api/domain.go
type Domain struct {
ID string `json:"id"`
Name string `json:"name"`
HasAdultContentProtection bool `json:"has_adult_content_protection"`
HasExecutableProtection bool `json:"has_executable_protection"`
HasPhishingProtection bool `json:"has_phishing_protection"`
HasVirusProtection bool `json:"has_virus_protection"`
IsGlobal bool `json:"is_global"`
MaxQuotaPerAlias int `json:"max_quota_per_alias"`
Plan string `json:"plan"`
RetentionDays int `json:"retention_days"`
SmtpPort int `json:"smtp_port"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Verification status
HasMxRecord bool `json:"has_mx_record"`
HasTxtRecord bool `json:"has_txt_record"`
// Members
Members []DomainMember `json:"members,omitempty"`
}
type DomainMember struct {
User DomainUser `json:"user"`
Group string `json:"group"`
}
type DomainUser struct {
ID string `json:"id"`
Email string `json:"email"`
}
type DomainListOptions struct {
Page int `json:"page,omitempty"`
Limit int `json:"limit,omitempty"`
Search string `json:"search,omitempty"`
Verified *bool `json:"verified,omitempty"`
Plan string `json:"plan,omitempty"`
Sort string `json:"sort,omitempty"`
Order string `json:"order,omitempty"`
}// pkg/api/alias_service.go
type AliasService struct {
client *Client
}
func (s *AliasService) List(ctx context.Context, domainID string, options AliasListOptions) (*AliasListResponse, error) {
url := fmt.Sprintf("/domains/%s/aliases", domainID)
if len(options.toQuery()) > 0 {
url += "?" + options.toQuery().Encode()
}
req, err := s.client.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(ctx, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result AliasListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}// pkg/api/alias.go
type Alias struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Recipients []string `json:"recipients"`
IsEnabled bool `json:"is_enabled"`
HasImap bool `json:"has_imap"`
HasPgp bool `json:"has_pgp"`
Labels []string `json:"labels"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AliasCreateRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Recipients []string `json:"recipients"`
Labels []string `json:"labels,omitempty"`
}// pkg/api/email_service.go
type EmailService struct {
client *Client
}
func (s *EmailService) Send(ctx context.Context, req EmailSendRequest) (*EmailSendResponse, error) {
httpReq, err := s.client.NewRequest(http.MethodPost, "/emails", req)
if err != nil {
return nil, err
}
resp, err := s.client.Do(ctx, httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result EmailSendResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}// pkg/api/email.go
type EmailSendRequest struct {
From string `json:"from"`
To []string `json:"to"`
Cc []string `json:"cc,omitempty"`
Bcc []string `json:"bcc,omitempty"`
Subject string `json:"subject"`
Text string `json:"text,omitempty"`
Html string `json:"html,omitempty"`
Attachments []EmailAttachment `json:"attachments,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}
type EmailAttachment struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Content string `json:"content"` // base64 encoded
}
type EmailSendResponse struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}// pkg/errors/errors.go
type APIError struct {
StatusCode int `json:"status_code"`
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
func (e *APIError) Error() string {
if e.Details != "" {
return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.Details)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
// Error type constants
const (
ErrCodeNotFound = "NOT_FOUND"
ErrCodeUnauthorized = "UNAUTHORIZED"
ErrCodeForbidden = "FORBIDDEN"
ErrCodeValidation = "VALIDATION_ERROR"
ErrCodeRateLimit = "RATE_LIMIT_EXCEEDED"
ErrCodeServerError = "INTERNAL_SERVER_ERROR"
)func (c *Client) checkResponse(resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
var apiErr APIError
if err := json.NewDecoder(resp.Body).Decode(&apiErr); err != nil {
return &APIError{
StatusCode: resp.StatusCode,
Code: "UNKNOWN_ERROR",
Message: fmt.Sprintf("HTTP %d", resp.StatusCode),
}
}
apiErr.StatusCode = resp.StatusCode
return &apiErr
}func (c *Client) NewRequest(method, url string, body interface{}) (*http.Request, error) {
fullURL := c.baseURL + url
var buf io.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
buf = bytes.NewBuffer(jsonBody)
}
req, err := http.NewRequest(method, fullURL, buf)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
return req, nil
}type ListOptions struct {
Page int `json:"page,omitempty"`
Limit int `json:"limit,omitempty"`
}
func (o ListOptions) toQuery() url.Values {
v := url.Values{}
if o.Page > 0 {
v.Set("page", strconv.Itoa(o.Page))
}
if o.Limit > 0 {
v.Set("limit", strconv.Itoa(o.Limit))
}
return v
}
type ListResponse struct {
Data interface{} `json:"data"`
Page int `json:"page"`
Limit int `json:"limit"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
}For detailed information on testing API integration, including mock server setup and integration test patterns, see Testing Strategy.
// Implement rate limiting for API calls
type RateLimiter struct {
limiter *rate.Limiter
}
func (c *Client) Do(ctx context.Context, req *http.Request) (*http.Response, error) {
// Wait for rate limiter
if err := c.rateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("rate limit wait failed: %w", err)
}
// Execute request
return c.httpClient.Do(req.WithContext(ctx))
}func NewClient(config ClientConfig) *Client {
return &Client{
httpClient: &http.Client{
Timeout: config.Timeout, // Default: 30s
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
},
},
}
}// Use json:",omitempty" for optional fields
// Ignore unknown fields in responses
type Domain struct {
ID string `json:"id"`
Name string `json:"name"`
// New fields added gracefully
NewField string `json:"new_field,omitempty"`
}// API version detection
func (c *Client) detectAPIVersion(ctx context.Context) (string, error) {
req, err := c.NewRequest(http.MethodGet, "/version", nil)
if err != nil {
return "", err
}
resp, err := c.Do(ctx, req)
if err != nil {
return "v1", nil // Default fallback
}
defer resp.Body.Close()
var version struct {
Version string `json:"version"`
}
json.NewDecoder(resp.Body).Decode(&version)
return version.Version, nil
}func (c *Client) setSecurityHeaders(req *http.Request) {
// Prevent credential leakage
req.Header.Set("Cache-Control", "no-store")
req.Header.Set("Pragma", "no-cache")
// Set secure user agent
req.Header.Set("User-Agent", fmt.Sprintf("forward-email-cli/%s", version.Version))
}func (c *Client) sanitizeForLogging(req *http.Request) *http.Request {
// Clone request for logging
clone := req.Clone(req.Context())
// Remove authorization header
clone.Header.Del("Authorization")
return clone
}- Account Management: Profile operations, quota monitoring
- Log Download: Respect 10/day limit with intelligent caching
- Webhook Management: Configure and test webhook endpoints
- Real-time Events: WebSocket/SSE for real-time updates
- Response Caching: Intelligent caching with TTL
- Retry Logic: Exponential backoff with jitter
- Circuit Breaker: Prevent cascading failures
- Metrics Collection: Performance and usage metrics
For more information on testing and contributing, see:
Last Updated: 2026-01-18