# Ürünler arası gelen posta API'si — tüketici tarafı referansı

**Kime:** başka bir 3E ürününü yazan geliştiriciye/agent'a (gözetim, compliance, sıradaki).
**Kaynak karar:** `docs/arch/mimari-kararlar.md` → **AD-29**. Bu doküman o kararın uygulamasıdır; çelişki olursa AD-29 esastır.
**Sunucu tarafı (bu depo):** `app/Http/Controllers/Handoff/InboundMailHandoffController.php`, `app/Http/Middleware/VerifyHandoffSignature.php`, `app/Support/Handoff/*`, `routes/web.php`.

---

## 1 · Ne olduğu, tek paragrafta

3E'nin bütün ürünleri **tek bir gelen posta alan adını** paylaşır (`teklif.3egrp.com`). Postmark'ta bir gelen posta alan adı **tüm Postmark genelinde benzersizdir ve tek bir kancaya bağlıdır** — yani mektubu alan yer tektir ve orası **eğitim uygulamasıdır**. Bir mektubun hangi ürüne ait olduğu **referans kodunun önekinden** okunur (`GZ-2026-7K4F` → gözetim). Eğitim, kendisine ait olmayan mektubu **kenara koyar ve saklar**; o mektubu **sahibi ürün kendisi çeker** ve işlediğini **ikinci bir çağrıyla** bildirir.

⛔ **Alma ile işleme ayrıdır.** Mektup, sen «işledim» diyene kadar bekliyor kalır ve her çekişte yine gelir. Çökersen, hat koparsa, kaydı yazarken hata alırsan mektup kaybolmaz.

⚠ Bunun bedeli: **aynı mektup birden fazla kez gelebilir.** Bu bir kusur değil, «en az bir kez» teslimin tanımıdır — onay çağrısı dönüş yolunda kaybolursa mektup bekliyor kalır. **Tekrar yazmamak senin işin** (bkz. §6).

---

## 2 · Adres ve uçlar

| Ortam | Kök |
|---|---|
| production | `https://egitim.3egrp.com` |
| local prod | `https://egitim.devprod.sets.fyi` |
| dev | `https://egitim.dev.sets.fyi` |

⚠ **Ofis/panel alt alan adı değil, ana alan adı.** Uçlar `ofis.` veya `panel.` altında değildir.

| Yöntem | Yol | Ne yapar |
|---|---|---|
| `GET` | `/api/posta/bekleyenler` | Sana ait, henüz onaylanmamış mektuplar (en eskiden başlayarak, **en fazla 50**) |
| `GET` | `/api/posta/{mektupId}/ek/{ekId}` | Bir ekin baytları |
| `POST` | `/api/posta/{mektupId}/islendi` | «Kendi kaydımı yazdım» — mektubu kapatan tek şey |

⚠ 50'lik sınır sayfalama değildir: onaylananlar listeden düştüğü için **tekrar sorarsan kalanı gelir**. Boş liste dönene kadar sor.

⚠ Uçlar dakikada **120 istekle** sınırlıdır (IP başına). Aşarsan `429` alırsın. Dakikada bir çeken bir tüketici bunu hiç görmez.

---

## 3 · Kimlik: her istek imzalanır

Bearer token yok, URL'de sır yok.

⛔ **Neden URL'de sır yok:** yoldaki her vekil sunucunun erişim kaydına ve o kaydın döndürülmüş kopyalarına yazılır. Bu sistemde böyle bir uç zaten var ve bilinen bir kusurdur (EGT-167); yenisi bunu tekrarlamaz. İmza **başlıkta** gider.

⛔ **Neden imza, bearer token değil:** çalınan bir bearer token kullanılabilir bir tokendır. İmza; **yöntemi, yolu, sorgu dizesini, gövdeyi ve zamanı** kapsar — yani yakalanan bir istek başka bir satıra, başka bir uca yöneltilemez ve yarın tekrar oynatılamaz.

### 3.1 · Başlıklar

| Başlık | Değer |
|---|---|
| `X-3E-Consumer` | Sana verilen tüketici adı (ör. `gozetim`) |
| `X-3E-Timestamp` | Unix zaman damgası, saniye, düz sayı |
| `X-3E-Signature` | Aşağıdaki HMAC'in **küçük harf hex** hâli |

### 3.2 · İmzalanan metin — birebir

Altı satır, `\n` ile birleşir, **bu sırayla ve başka türlü değil**:

```
v1
<tüketici adı>
<YÖNTEM, büyük harf>
<baştaki eğik çizgiyle yol, sorgu dizesi varsa dahil>
<unix zaman damgası>
<ham gövdenin sha256'sı, hex; gövde yoksa boş dizenin sha256'sı>
```

```
signature = hash_hmac('sha256', base, secret)   // hex, küçük harf
```

⚠ **Gövde HASH'lenir, gömülmez** — megabaytlar üzerinden hesaplanan bir imza kimsenin iki kez hesaplamadığı imzadır.

⚠ **«Ham gövde», gerçekten gönderdiğin baytlardır.** Hiçbir şey göndermiyorsan boş dizeyi (`e3b0c442...`) hash'lersin; istemcin `[]` veya `{}` yazıyorsa **onu** hash'lersin. Onay ucu gövdeye bakmaz; ama imza baktığı için yanlış tahmin `401` üretir — bu, tüketici yazarken en sık düşülen tuzaktır.

⚠ **Yol, sorgu dizesi dahil imzalanır.** Bugün hiçbir uç sorgu parametresi almıyor; sorgu eklersen imzaya da girer.

⚠ İlk satırdaki `v1` sürümdür. Şema değişirse iki taraf da hangi şemadan bahsedildiğini tahmin etmek zorunda kalmasın diye oradadır.

### 3.3 · Boş gövdenin sha256'sı

```
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

### 3.4 · Örnek

Sır `s3cr3t`, tüketici `gozetim`, zaman damgası `1786000000`, `GET /api/posta/bekleyenler`, gövde yok:

```
v1\ngozetim\nGET\n/api/posta/bekleyenler\n1786000000\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

### 3.5 · Reddedilme kuralları

Sunucu şu üçünü **bu sırayla** kontrol eder: tanınmayan tüketici adı → zaman damgası penceresi → imza.

⛔ Üçü de tek bir **`401`** ile ve **hiçbir ayrıntı verilmeden** cevaplanır. «Tüketici yok» ile «imza tutmadı» ayrı cümleler olsaydı, saldırgana bedavaya çalışan bir tüketici adı verilmiş olurdu. Yani `401` alıyorsan hangi adımda düştüğünü sunucu sana **söylemeyecektir**; §9'daki listeyi sırayla kontrol et.

- **Zaman penceresi:** varsayılan **±300 saniye**. Sunucunun saatiyle seninki bu kadar ayrılabilir. ⛔ Sunucun NTP ile senkron değilse, imzan doğru olsa bile reddedilirsin — bu, tüketici tarafında ikinci sık tuzaktır.
- **Karşılaştırma sabit zamanlıdır** (`hash_equals`). Sen de kendi tarafında sır karşılaştırırsan aynısını yap.

---

## 4 · Cevaplar

### 4.1 · `GET /api/posta/bekleyenler` → `200`

```json
{
  "letters": [
    {
      "id": 41,
      "message_id": "a1b2c3d4-0000-0000-0000-postmark",
      "reference_code": "GZ-2026-7K4F",
      "from": "musteri@firma.com.tr",
      "to": "GZ-2026-7K4F@teklif.3egrp.com",
      "subject": "Teklifiniz hakkında",
      "body": "Fiyatı onaylıyoruz.",
      "received_at": "2026-08-16T09:14:22+00:00",
      "delivered_count": 1,
      "attachments": [
        {
          "id": 7,
          "filename": "imzali-teklif.pdf",
          "mime_type": "application/pdf",
          "byte_size": 481233,
          "sha256": "9f2c…"
        }
      ]
    }
  ]
}
```

| Alan | Anlamı ve dikkat |
|---|---|
| `id` | **Bu sistemdeki** mektup numarası. Ek indirmede ve onayda kullanılır. Kendi tarafında sakla ama **kimliği bu olmasın** — bkz. `message_id`. |
| `message_id` | ⛔ **Tekrar denetimini bunun üzerinden yap.** Posta sağlayıcısının mektup kimliğidir ve mektup kaç kez teslim edilirse edilsin değişmez. |
| `reference_code` | Mektubun ait olduğu kayıt. Kendi tarafındaki kaydı bununla bulacaksın. |
| `body` | Düz metin. HTML gövde **taşınmaz** — gelen posta hiçbir iş kuralını tetiklemez, okunur (AD-20 §1). |
| `received_at` | Mektubun buraya ulaştığı an, ISO-8601, UTC. |
| `delivered_count` | Bu mektubun sana kaçıncı kez verildiği. **1'den büyükse önceki turda onaylamamışsın demektir** — kendi tarafında bunu izle. |
| `attachments[].sha256` | Ekin baytlarının özeti. ⛔ İndirdiğin baytların özetini **kendin de hesapla ve karşılaştır**; tutmuyorsa kaydetme. |

⚠ **Eklerin baytları bu cevapta gelmez**, yalnız künyesi gelir. 10 MB'lık bir ek listeyi kullanılamaz hâle getirmesin diye ekler tek tek çekilir.

⚠ Bu uç **verdiği her mektubun sayacını artırır** — sen cevabı okumasan bile. Sayaç, sessizce başarısız olan bir tüketiciyi gösteren tek işarettir.

### 4.2 · `GET /api/posta/{mektupId}/ek/{ekId}` → `200`

Baytlar; `Content-Disposition: attachment`. ⚠ Ek, **daima** dışarıdan gelmiş, doğrulanmamış içeriktir: kendi tarafında da tarayıcıda açtırma, `attachment` olarak sun.

### 4.3 · `POST /api/posta/{mektupId}/islendi` → `200`

Gövde beklenmez. Cevap:

```json
{ "acknowledged": true, "acknowledged_at": "2026-08-16T09:15:03+00:00" }
```

⚠ **İkinci kez onaylamak da başarıdır.** İlk çağrının dönüş yolunda kaybolup kaybolmadığını bilemezsin; buranın `404` ya da `409` dönmesi seni çıkamayacağın bir yeniden deneme döngüsüne sokardı.

### 4.4 · Hata kodları

| Kod | Ne demek | Ne yapmalısın |
|---|---|---|
| `401` | İmza/zaman/tüketici — hangisi olduğu söylenmez | §9. **Yeniden deneme döngüsüne girme**, yapılandırman bozuktur |
| `404` | Böyle bir mektup **senin için** yok (yoktur ya da başkasınındır) | Kendi tarafında kapat, tekrar isteme |
| `429` | Dakikalık sınır | Geri çekil, bekle |
| `5xx` | Bizim tarafımız | Üstel geri çekilmeyle tekrar dene; mektup kaybolmaz |

⚠ `404`'ün iki sebebi ayırt edilmez ve bu bilinçlidir: ayırt edilseydi bir ürün, diğerinin hangi numaralı mektupları aldığını sayabilirdi.

---

## 5 · Yetki modeli — okumadan geçme

⛔ **Her sorgu, İMZANIN çözdüğü tüketiciyle daraltılır.** Hangi öneklerin kime ait olduğu **yapılandırmada** durur; istekte taşınmaz. İstekte taşınsaydı, bir ürün kendi geçerli imzasıyla başka bir ürünün müşteri yazışmasını okuyabilirdi.

Pratik sonuçları:

- Başka bir ürünün mektup numarasını denemenin faydası yoktur — `404` alırsın.
- Sana ait olmayan bir mektubu onaylayamazsın.
- Bir ekin numarasını tek başına yürütemezsin; ek **mektubu üzerinden** adreslenir.

---

## 6 · Tüketici tarafında kurulacak döngü

```
her N dakikada bir:
  1. GET /api/posta/bekleyenler
  2. her mektup için:
       a. message_id'yi kendi tablonda ara → varsa 2d'ye atla (tekrar yazma)
       b. reference_code ile kendi kaydını bul
             bulamazsan: mektubu KENDİ tarafında ayıklama kutusuna yaz — asla düşürme
       c. varsa ekleri indir, sha256'yı doğrula, kendi deponda sakla
       d. POST /api/posta/{id}/islendi
  3. liste doluysa (50) hemen tekrar sor; boşsa bir sonraki tura bırak
```

⛔ **Adım (d), kendi kaydın yazıldıktan SONRA gelir.** Önce onaylayıp sonra yazarsan, arada çökme ihtimalinde mektup kaybolur — bu ucun ayrı bir çağrı olmasının tek sebebi budur.

⛔ **Kendi tarafında `message_id` üzerinde tekil (unique) bir kısıt kur.** «Zaten var mı» diye bakmak yetmez; iki tur çakışırsa ikisi de «yok» görür. Kısıt veritabanında olsun.

⛔ **Hiçbir mektup düşürülmez.** Tanımadığın bir referans kodu, biçimi bozuk bir kayıt, beklemediğin bir ek — hepsi saklanır ve bir insanın önüne konur. Sessizce atılan bir mektup, görülemeyen tek arıza türüdür (AD-20).

⚠ **Sıklık:** dakikada birden sık sorma; işin doğası dakikalarla ölçülüyor ve sınır 120/dk.

⚠ **Tek koşu:** çekme işini kilitle koru (Laravel'de `withoutOverlapping`). İki koşunun aynı anda çekmesi tekrar yazma riskini artırır.

⚠ **`delivered_count > 3` gördüğün mektup için kendi tarafında alarm üret** — bu, senin döngünün onaylayamadığı bir mektup demektir ve sessizce dönüyor demektir.

---

## 7 · Örnek istemci (Laravel/PHP)

⚠ Bu örnek imzayı ve döngüyü gösterir; kendi projendeki katman kurallarına uydur.

```php
final class HandoffClient
{
    public function __construct(
        private readonly string $baseUrl,   // config, env'den
        private readonly string $consumer,  // 'gozetim'
        private readonly string $secret,    // env, asla kodda değil
    ) {}

    /** @return array<int, array<string, mixed>> */
    public function waiting(): array
    {
        return $this->send('GET', '/api/posta/bekleyenler')->json('letters') ?? [];
    }

    public function acknowledge(int $letterId): void
    {
        $this->send('POST', '/api/posta/'.$letterId.'/islendi');
    }

    public function attachment(int $letterId, int $attachmentId): string
    {
        return $this->send('GET', '/api/posta/'.$letterId.'/ek/'.$attachmentId)->body();
    }

    private function send(string $method, string $pathWithQuery, string $body = ''): Response
    {
        $timestamp = time();

        $base = implode("\n", [
            'v1',
            $this->consumer,
            strtoupper($method),
            $pathWithQuery,
            (string) $timestamp,
            hash('sha256', $body),
        ]);

        return Http::withHeaders([
            'X-3E-Consumer' => $this->consumer,
            'X-3E-Timestamp' => (string) $timestamp,
            'X-3E-Signature' => hash_hmac('sha256', $base, $this->secret),
        ])
            ->timeout(30)
            ->withBody($body, 'application/json')   // gövde ne ise imzalanan da odur
            ->send($method, $this->baseUrl.$pathWithQuery)
            ->throw();
    }
}
```

⚠ `withBody($body)` ile imzaladığın `$body` **aynı değişken** olmalı. İstemcinin gövdeyi arkanda yeniden biçimlendirmesi (`json()` yardımcıları bunu yapar) imzayı bozar.

---

## 8 · Yeni bir tüketici bağlamak (eğitim tarafında yapılacaklar)

Bunlar **bu depoda** yapılır; tüketici tarafındaki geliştirici bunları isteyerek başlar.

1. **Önek kararlaştırılır.** ⛔ Önek ürünü söylemek zorundadır. İki ürün de teklif için `TK` kullanırsa `TK-2026-7K4F` iki ayrı kayda karşılık gelir ve mektup kime ait olduğu bilinemez. Bugünkü sözlük: `app/Domain/Shared/ReferencePrefix.php`.
2. `config/mail.php` → `handoff_consumers` dizisine tüketici satırı eklenir (ad, sır anahtarı, önekler).
3. Sır üretilir (`openssl rand -hex 32`) ve **iki tarafın env'ine** yazılır: burada `MAIL_HANDOFF_SECRET_<AD>`, önekler `MAIL_HANDOFF_PREFIXES_<AD>` (virgülle).
   ⛔ Sır **kodda, dokümanda, ticket'ta ya da sohbette yazılmaz**; `.env` şablonlarında yalnız **boş** durur.
   ⚠ Sır **tüketici başına ayrıdır.** Bir sırrı iki üründe kullanmak, birini döndürmeyi ikisini birden kırmak hâline getirir.
4. ⚠ **Yarım yapılandırma diye bir şey yoktur:** sır boşsa ya da önek listesi boşsa o tüketici **hiç var olmaz** (`HandoffConsumer::named()`). Kimseyi doğrulamayan ya da herkesi yetkilendiren bir kapı bu yüzden oluşamaz.
5. Sır döndürmek: iki taraf da aynı anda değiştirir. ⚠ Kesintisiz döndürme desteklenmiyor — çekme döngüsü zaten yeniden dener, tek turluk `401` kayba yol açmaz.

---

## 9 · `401` alıyorsan — sırayla kontrol et

Sunucu hangi adımda düştüğünü söylemez; liste bu yüzden var.

1. `X-3E-Consumer` **birebir** yapılandırılan ad mı? (`gozetim`, `Gozetim` değil.)
2. Sır iki tarafta aynı mı? Sonunda görünmez bir satır sonu var mı? (`.env`'e kopyalarken olur.)
3. Zaman damgası **saniye** mi (milisaniye değil), ve sunucunun saati doğru mu? `abs(fark) > 300` reddedilir.
4. İmzaladığın gövde, gönderdiğin baytların **aynısı** mı? İstemcin `[]`, `{}` ya da boş satır ekliyor olabilir.
5. Yol, baştaki eğik çizgiyle ve **tam** mı? (`/api/posta/bekleyenler`; kök adres imzaya girmez, yalnız yol girer.)
6. Yöntem büyük harf mi? (`GET`, `get` değil.)
7. İmza **hex ve küçük harf** mi? (Base64 değil.)
8. Yönlendirme (redirect) yiyor musun? `http` → `https` yönlendirmesi `POST`'u `GET`'e çevirir ve imza artık başka bir isteğe aittir. Doğrudan `https` çağır.

---

## 10 · Tüketici tarafında yazılacak testler (asgari)

Bu listenin sunucu tarafındaki karşılığı `tests/Feature/Entegrasyon/InboundMailHandoffTest.php`'dedir; iddiaların aynısını kendi tarafında da doğrula.

- [ ] İmzasız istek reddedilir (kendi istemcin sırsız çalışmaz).
- [ ] Yanlış sırla üretilen imza `401` alır.
- [ ] Aynı `message_id` iki kez geldiğinde **tek kayıt** oluşur.
- [ ] Kendi kaydın yazılmadan onay **gönderilmez** (kayıt yazımı hata verirse onay atlanır).
- [ ] Onay çağrısı hata verirse mektup bir sonraki turda tekrar işlenir ve yine tek kayıt oluşur.
- [ ] Tanınmayan `reference_code` düşürülmez, ayıklama kutusuna yazılır.
- [ ] Ekin `sha256`'sı tutmuyorsa ek kaydedilmez ve durum görünür kılınır.
- [ ] Liste 50 dolu döndüğünde döngü tekrar sorar.

---

## 11 · Kapsam dışı — istemeyin, yok

- **İtme (webhook) yok.** Eğitim size çağrı yapmaz. Sebep AD-29'da: itme modeli eğitimi sizin sağlığınızdan sorumlu kılar (yeniden deneme, geri çekilme, ölü mektup kutusu, «üç saattir cevap vermiyor» alarmı). Çeken taraf, ayakta olup olmadığını bilen tek taraftır.
- **Giden posta yok.** Bu uçlar yalnız gelen postayı devreder. Kendi teklif postanızı kendiniz gönderirsiniz; gönderen adresi aynı alan adındandır (`GZ-2026-7K4F@teklif.3egrp.com`) ve alan adı doğrulaması bütün adresleri kapsar, ürün başına kurulum gerekmez.
- **İş kuralı yok.** Gelen posta hiçbir şeyi tetiklemez, tetiklemesi de istenmez (AD-20 §1). Mektuptan ne çıkaracağınız sizin tarafınızın kararıdır.
- **Filtre yok.** «Yalnız şu konudakileri ver» gibi bir parametre yoktur; alan daraltma imzanın çözdüğü tüketiciyle olur, istekle değil.
