# Adapter Reference

Mailforge spricht NIE direkt mit CRMs oder Mail-Providern. Stattdessen geht jeder Aufruf durch eine **Adapter-Schicht**: einen TypeScript-Interface-Vertrag, der die nötigen Operationen abstrahiert. Integratoren liefern eigene Implementierungen und registrieren sie in `adapters.config.ts`.

Es gibt zwei Adapter-Typen:

| Typ | Wofür | Interface | Defaults |
|---|---|---|---|
| `CrmAdapter` | Empfänger-Lookup, Conversion-Polling | `src/adapters/crm/interface.ts` | Generic-REST, Cumas (Beispiel) |
| `SendProvider` | Mail-Versand | `src/adapters/send/interface.ts` | SMTP, M365 (Beispiel) |

---

## CrmAdapter

### Interface

```typescript
import type { UserContext } from '@/core/types';
import type { Contact, ContactFilter, ConversionEvent } from '@/adapters/types';

export interface CrmAdapter {
  readonly name: string;
  isConfigured(): boolean;

  // Required
  listContacts(filter: ContactFilter, ctx: UserContext): Promise<Contact[]>;
  getContactById(id: string, ctx: UserContext): Promise<Contact | null>;
  pollConversions(since: Date, ctx: UserContext): Promise<ConversionEvent[]>;

  // Optional
  listSegments?(ctx: UserContext): Promise<Array<{ id: string; label: string; count?: number }>>;
  applyTags?(contactId: string, tags: string[], ctx: UserContext): Promise<void>;
}
```

### Eigenen CrmAdapter schreiben

**Beispiel: HubSpot-Adapter**

```typescript
// src/adapters/crm/hubspot.ts
import type { CrmAdapter } from './interface';
import { getSecret } from '@/core/settings';

export const hubspotAdapter: CrmAdapter = {
  name: 'hubspot',

  isConfigured() {
    return Boolean(getSecret('crm_adapter_config'));
  },

  async listContacts(filter, ctx) {
    const config = JSON.parse(getSecret('crm_adapter_config') ?? '{}');
    const res = await fetch(`https://api.hubapi.com/crm/v3/objects/contacts?limit=${filter.limit ?? 100}`, {
      headers: { Authorization: `Bearer ${config.privateAppToken}` },
    });
    const data = await res.json() as { results: Array<{ id: string; properties: Record<string, string> }> };
    return data.results.map((r) => ({
      externalId: r.id,
      email: r.properties.email,
      firstName: r.properties.firstname,
      lastName: r.properties.lastname,
      company: r.properties.company,
    }));
  },

  async getContactById(id, ctx) {
    const config = JSON.parse(getSecret('crm_adapter_config') ?? '{}');
    const res = await fetch(`https://api.hubapi.com/crm/v3/objects/contacts/${id}`, {
      headers: { Authorization: `Bearer ${config.privateAppToken}` },
    });
    if (!res.ok) return null;
    const r = await res.json();
    return { externalId: r.id, email: r.properties.email };
  },

  async pollConversions(since, ctx) {
    // HubSpot stores conversions as deals or specific event types.
    // Implement based on your HubSpot setup.
    return [];
  },

  async listSegments(ctx) {
    // HubSpot lists = segments
    const config = JSON.parse(getSecret('crm_adapter_config') ?? '{}');
    const res = await fetch('https://api.hubapi.com/contacts/v1/lists', {
      headers: { Authorization: `Bearer ${config.privateAppToken}` },
    });
    const data = await res.json();
    return data.lists.map((l: { listId: number; name: string; metaData: { size: number } }) => ({
      id: String(l.listId),
      label: l.name,
      count: l.metaData.size,
    }));
  },
};
```

In `adapters.config.ts` registrieren:

```typescript
import { hubspotAdapter } from '@/adapters/crm/hubspot';

export const adapters = {
  crm: hubspotAdapter,
  send: smtpProvider,
};
```

### Wichtige Regeln

- **Workspace-Scoping**: `ctx.workspaceIds` enthält die erlaubten Workspaces des Users. Wenn dein CRM Multi-Mandant ist, filtere mit `ctx.workspaceIds` damit User A nicht User B's Contacts sieht.
- **Secrets**: Nutze `getSecret(key)` aus `@/core/settings` — verschlüsselt at rest. Niemals API-Keys direkt aus env hardcoden.
- **Mock-Mode bei missing config**: Wenn `isConfigured()` false ist, soll `listContacts` ein leeres Array zurückgeben — kein Throw. Das hält die UI funktional.
- **Pagination**: Wenn dein CRM langsam ist, batche intern. `filter.limit`/`offset` sind Hints, keine Pflicht.

---

## SendProvider

### Interface

```typescript
import type { UserContext } from '@/core/types';
import type { SendMailInput, SendResult } from '@/adapters/types';

export interface SendProvider {
  readonly name: string;
  isConfigured(): boolean;
  send(input: SendMailInput, ctx?: UserContext): Promise<SendResult>;
  ping(senderEmail: string): Promise<{ ok: boolean; error?: string }>;
  getQuotas?(): { dailyLimit: number; perMinute: number };
}
```

### Eigenen SendProvider schreiben

**Beispiel: Resend-Adapter**

```typescript
// src/adapters/send/resend.ts
import type { SendProvider } from './interface';
import { getSecret } from '@/core/settings';

export const resendProvider: SendProvider = {
  name: 'resend',

  isConfigured() {
    return Boolean(getSecret('send_provider_config'));
  },

  async send(input, ctx) {
    const config = JSON.parse(getSecret('send_provider_config') ?? '{}') as { apiKey: string };

    const res = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${config.apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        from: input.from.name ? `${input.from.name} <${input.from.email}>` : input.from.email,
        to: input.to,
        cc: input.cc,
        bcc: input.bcc,
        subject: input.subject,
        html: input.bodyHtml,
        text: input.bodyText,
        headers: input.headers,
      }),
    });

    if (!res.ok) {
      const errBody = await res.json().catch(() => ({ message: 'Unknown error' }));
      return {
        ok: false,
        error: errBody.message ?? 'Resend send failed',
        errorKind: res.status === 401 ? 'auth' : res.status === 429 ? 'rate-limit' : 'rejected',
        httpStatus: res.status,
      };
    }

    const { id } = await res.json() as { id: string };
    return { ok: true, messageId: id };
  },

  async ping(senderEmail) {
    const config = JSON.parse(getSecret('send_provider_config') ?? '{}') as { apiKey: string };
    // Resend hat kein dediziertes ping — wir checken die Domains-Liste
    const res = await fetch('https://api.resend.com/domains', {
      headers: { Authorization: `Bearer ${config.apiKey}` },
    });
    if (!res.ok) return { ok: false, error: `Resend API HTTP ${res.status}` };
    const data = await res.json() as { data: Array<{ name: string }> };
    const senderDomain = senderEmail.split('@')[1];
    const hasDomain = data.data.some((d) => d.name === senderDomain);
    return hasDomain ? { ok: true } : { ok: false, error: `Domain ${senderDomain} nicht in Resend-Account` };
  },

  getQuotas() {
    return { dailyLimit: 100, perMinute: 10 }; // Free tier — anpassen pro Plan
  },
};
```

### SendResult.errorKind

Der Worker entscheidet anhand von `errorKind`, ob retried wird:

| errorKind | Worker-Verhalten |
|---|---|
| `network` | Retry (3x mit 5/15/60 Min Backoff) |
| `rate-limit` | Retry (3x) |
| `auth` | Retry (eventuell Token expired) |
| `config` | Kein Retry — markiert als `failed` |
| `rejected` | Kein Retry (Empfänger-spezifisch oder Content-Issue) |
| `unknown` | Retry (vorsichtig) |

### Wichtige Regeln

- **Idempotency**: Wenn dein Provider `messageId` (RFC 5322) unterstützt, übernimm `input.messageId` damit Doppel-Sends erkannt werden.
- **From-Resolution**: Mailforge liefert die volle `from`-Adresse — nie nachträglich überschreiben.
- **Audit-BCC**: Wenn dein Setup eine Audit-Inbox braucht, ist das Provider-Sache (siehe SMTP-Adapter mit `auditBcc`-Config). Mailforge fügt keinen globalen BCC hinzu.
- **No retries inside `send()`**: Der Worker handhabt Retries. Provider soll bei einem Fehlversuch sofort zurückkehren.

---

## Adapter-Auswahl in der Settings-UI

Beide Adapter-Typen lesen ihre Konfiguration aus dem **verschlüsselten Settings-Store** (`appSettings` Tabelle, AES-256-GCM verschlüsselt mit `APP_ENCRYPTION_KEY`):

| Setting-Key | Verwendung |
|---|---|
| `crm_adapter_config` | JSON für aktiven CrmAdapter (Endpoints, Auth) |
| `send_provider_config` | JSON für aktiven SendProvider (SMTP-Host, Resend-API-Key, M365-Tenant, …) |
| `openrouter_api_key` | LLM-Gateway-Schlüssel |
| `fal_api_key` | Hero-Image-Schlüssel |

Die Settings-UI rendert generische JSON-Editoren oder strukturierte Formulare je nach Adapter-Typ. Eigene Adapter können einen UI-Hint im Manifest registrieren (Phase G).

---

## Adapter-Tests

Beispiel-Smoke-Test für einen neuen Adapter:

```typescript
// scripts/test-adapter.ts
import { adapters } from '@/adapters.config';

async function main() {
  console.log(`CRM adapter: ${adapters.crm.name}, configured: ${adapters.crm.isConfigured()}`);
  console.log(`Send adapter: ${adapters.send.name}, configured: ${adapters.send.isConfigured()}`);

  if (adapters.crm.isConfigured()) {
    const contacts = await adapters.crm.listContacts(
      { workspaceId: 'ws_test', limit: 5 },
      { userId: 'test', workspaceIds: ['ws_test'], permissions: [] },
    );
    console.log(`Got ${contacts.length} contacts`);
  }

  if (adapters.send.isConfigured()) {
    const ping = await adapters.send.ping('test@example.com');
    console.log(`Send ping: ${ping.ok ? 'OK' : 'FAIL — ' + ping.error}`);
  }
}

main();
```

Run: `npx tsx scripts/test-adapter.ts`
