Ek: Python ile e-posta göndermek

Python ile e-posta göndermek: SMTP ve IMAP

Polimorfizm ve duck typing bölümündeki EmailNotification sınıfı iletiyi kendisi göndermiyordu. İletiyi nitelikte tuttuğu smtp nesnesine veriyordu. Bu sayfada aynı sınıfla önce tarayıcıda sahte bir sunucuya, sonra kendi bilgisayarınızda çalışan gerçek bir SMTP sunucusuna e-posta göndereceksiniz. Ardından gönderim başarısız olunca ne olduğuna bakacak, kodu 13. bölümdeki gibi bir pakete ayıracak ve posta kutusunu okuyan IMAP koduna göz atacaksınız.

Sayfadaki kod hücreleri kendiliğinden çalışmaz. Önce sonucu tahmin edin, sonra Run Code düğmesine basın. Tarayıcıdaki hücreler internete bağlanamaz. Gerçek gönderim adımlarını bilgisayarınızda, 1. haftada kurduğunuz Python ve VS Code ile yapacaksınız.

Bir e-postanın yolu

Bir e-posta alıcısına tek adımda gitmez. Yolda iki ayrı protokol kullanılır. Protokol, iki programın birbiriyle konuşurken uyduğu kurallardır. Posta sunucusu ise iletileri kabul eden, saklayan ve başka sunuculara ileten, sürekli açık duran programdır. Okulun da Gmail’in de kendi posta sunucuları var.

Adım Nereden Nereye Protokol Ne olur?
1 Gönderenin programı Gönderenin posta sunucusu SMTP Program iletiyi sunucuya teslim eder.
2 Gönderenin posta sunucusu Alıcının posta sunucusu SMTP Sunucu iletiyi alıcının sunucusuna aktarır. İleti orada alıcının posta kutusuna konur.
3 Alıcının posta sunucusu Alıcının programı IMAP Alıcının programı sunucudaki kutuyu okur. İleti sunucuda kalır.

SMTP (Simple Mail Transfer Protocol) iletiyi göndermek ve sunucudan sunucuya aktarmak için kullanılır. IMAP (Internet Message Access Protocol) ise sunucudaki posta kutusunu okumak ve yönetmek içindir: okunmamış iletileri listelemek, bir iletiyi okundu diye işaretlemek ya da başka klasöre taşımak gibi. Telefonunuzdaki posta uygulaması gelen kutusunu IMAP ile okur. İleti sunucuda durduğu için bilgisayardan baktığınızda da aynı kutuyu görürsünüz.

Gerçek sunucularda gönderim için genellikle 587, IMAP için 993 numaralı port kullanılır ve iki bağlantı da şifrelenir. Port, bir bilgisayarda çalışan programlardan hangisine bağlanılacağını gösteren numaradır. Portların ve sunucuların ayrıntısı Ağ Temelleri dersinin konusu.

Python’da SMTP için smtplib, IMAP için imaplib modülü var. İletinin kendisini ise standart kütüphanedeki email paketinin EmailMessage sınıfıyla oluşturursunuz.

Tarayıcıda: ileti oluşturmak ve sahte sunucuya göndermek

İletinin ham metni

EmailMessage nesnesi bir sözlük gibi başlık alır: msg["Subject"] = ... konu başlığını yazar, msg["Subject"] okur. set_content() iletinin gövdesini, yani asıl metnini yazar. as_bytes() iletiyi SMTP’nin göndereceği baytlara çevirir, decode() ise bu baytları yeniden okunabilir metne çevirir. Konu satırı ham metinde nasıl görünür?

Sunucuya giden şey bu metindir: üstte başlık satırları, bir boş satır, altta gövde. SMTP başlıklarda yalnız ASCII karakterlerini (İngilizce harfler, rakamlar ve temel işaretler) bekler. Bu yüzden EmailMessage konu satırındaki Türkçe harfleri =?utf-8?q?...?= biçiminde kodlar. Alıcının programı bu kodu çözer ve konuyu Kulüp toplantısı diye gösterir.

Sahte sunucuyla göndermek

FakeSmtp iletileri bir yere göndermez, sent listesinde tutar. EmailNotification ise sunucu nesnesini smtp niteliğinde tutar ve send() içinde onun send_message() metodunu çağırır. Hücre hangi iki satırı yazar?

EmailNotification sınıfının içinde FakeSmtp adı hiç geçmez. Sınıf, nitelikteki nesnenin send_message(msg) metodu olmasını bekler, o kadar. Standart kütüphanedeki smtplib.SMTP sınıfında da aynı adlı, aynı argümanı alan bir metot var. Sıradaki adımda FakeSmtp yerine bu gerçek nesneyi vereceksiniz.

Kendi bilgisayarınızda gerçek bir SMTP sunucusu

Gerçek bir posta sunucusuna göndermek için hesap ve parola gerekir. Denemek için bunlara gerek yok: kendi bilgisayarınızda yalnız deneme için çalışan bir SMTP sunucusu açabilirsiniz. Bu sunucu iletiyi kimseye iletmez, aldığı iletiyi terminale yazar. Sunucu için aiosmtpd paketini kuracaksınız. Paket Python’la birlikte gelmez, PyPI’dan (pip install ile paket indirilen ortak depo) indirilir.

UyarıEski anlatımlardaki python -m smtpd artık çalışmaz

İnternette python -m smtpd komutunu öneren eski yazılar bulabilirsiniz. smtpd modülü Python 3.12’de standart kütüphaneden kaldırıldı. 1. haftada kurduğunuz sürümde bu komut No module named smtpd hatası verir. Yerine aiosmtpd kullanın.

1. Sunucuyu kurun ve başlatın

VS Code’da bu çalışma için bir klasör açın ve Terminal > New Terminal ile bir terminal açın. Paketi kurun:

py -m pip install aiosmtpd

macOS ve Linux’ta py yerine python3 yazın. Kurulum bitince sunucuyu başlatın:

py -m aiosmtpd -n -l localhost:8025

-l localhost:8025 sunucunun yalnız sizin bilgisayarınızdan gelen bağlantıları, 8025 numaralı portta dinlemesini söyler. -n olmadan sunucu Windows’ta açılmaz: Cannot import module "pwd"; try running with -n option. yazıp kapanır.

Sunucu başlayınca terminal hiçbir şey yazmadan bekler. Sorun yok: sunucu çalışıyor, ileti bekliyor. Bu terminali kapatmayın. Sunucuyu durdurmak istediğinizde bu terminalde Ctrl+C tuşlarına basın.

2. İkinci terminalden ileti gönderin

Terminal panelindeki + düğmesiyle ikinci bir terminal açın. Klasöre gonder.py adında bir dosya oluşturun:

import smtplib
from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "[email protected]"
msg["To"] = "[email protected]"
msg["Subject"] = "Kulüp toplantısı"
msg.set_content("Toplantı yarın 14.00'te, B blok 204'te.")

with smtplib.SMTP("localhost", 8025) as smtp:
    smtp.send_message(msg)

print("İleti gönderildi.")

İkinci terminalde çalıştırın:

py gonder.py

Bu terminalde İleti gönderildi. yazar. Sunucunun terminalinde ise iletinin kendisi görünür:

---------- MESSAGE FOLLOWS ----------
From: [email protected]
To: [email protected]
Subject: =?utf-8?q?Kul=C3=BCp_toplant=C4=B1s=C4=B1?=
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 8bit
MIME-Version: 1.0
X-Peer: ('::1', 64446, 0, 0)

Toplantı yarın 14.00'te, B blok 204'te.
------------ END MESSAGE ------------

Tarayıcıda as_bytes() ile gördüğünüz metnin aynısı. Fazladan yalnız X-Peer satırı var. Bu satırı sunucu ekler: bağlanan programın adresini ve portunu gösterir. Sayılar sizde farklı çıkar.

smtplib.SMTP("localhost", 8025) sunucuya bağlanır. with bloğu bitince bağlantı kapanır. Bloğun içinde bir hata çıksa da kapanır. 5. haftada dosyayı kapatan with open(...) ile aynı fikir: açtığınız şeyi kapatmayı Python’a bırakırsınız.

ÖnemliDosyanıza email.py ya da smtplib.py adını vermeyin

Dosyanızın adı standart kütüphanedeki bir modülle aynıysa Python import satırında sizin dosyanızı bulur. Dosyanın adı email.py olursa from email.message import EmailMessage satırı ModuleNotFoundError: No module named 'email.message'; 'email' is not a package hatası verir. smtplib.py adında da smtplib.SMTP bulunamaz. gonder.py gibi başka bir ad seçin.

3. Aynı sınıf, gerçek sunucu

Şimdi EmailNotification sınıfını hiç değiştirmeden gerçek sunucuyla kullanın. gonder.py dosyasının içeriğini şununla değiştirip çalıştırın:

import smtplib
from email.message import EmailMessage


class EmailNotification:
    def __init__(self, smtp, to):
        self.smtp = smtp
        self.to = to

    def send(self, message):
        msg = EmailMessage()
        msg["From"] = "[email protected]"
        msg["To"] = self.to
        msg["Subject"] = "Okul duyurusu"
        msg.set_content(message)
        self.smtp.send_message(msg)
        return f"E-posta: {message}"


with smtplib.SMTP("localhost", 8025) as smtp:
    notification = EmailNotification(smtp, "[email protected]")
    notification.send("Kulüp toplantısı yarın 14.00'te.")
    notification.send("Kütüphane cuma günü kapalı.")

print("Duyurular gönderildi.")

Sunucunun terminalinde iki ileti görünür. Tarayıcıda FakeSmtp nesnesini alan sınıf, burada smtplib.SMTP nesnesini aldı. İki sınıfın ortak bir üst sınıfı yok. İkisinde de send_message(msg) metodu olması yetti. 11. bölümdeki duck typing budur: sınıf, nesnenin hangi sınıftan geldiğine değil, hangi metotları olduğuna bakar. Sahte sunucunun yararı da buradan gelir. Sınıfı internet ve hesap olmadan deneyebilirsiniz, gerçek gönderimde tek bir satırını değiştirmezsiniz.

Gönderim başarısız olursa

Sunucu kapalıyken

Sunucunun terminalinde Ctrl+C ile sunucuyu durdurun ve py gonder.py komutunu yeniden çalıştırın. Hata mesajının son satırı şöyle başlar:

ConnectionRefusedError: [WinError 10061]

Satırın devamında Windows’un kendi dilinde bir açıklama gelir. macOS’ta satır ConnectionRefusedError: [Errno 61] Connection refused olur. Windows’ta hatanın gelmesi birkaç saniye sürebilir. 8025 numaralı portu dinleyen bir program olmadığı için bilgisayar bağlantıyı reddetti. Program SMTP konuşmasına hiç başlayamadı.

Sunucu açık, ama ileti gönderilemiyor

Sunucuyu yeniden başlatın. Bu kez iletiye To başlığını yazmayı unutalım. Alıcısı olmayan bir ileti gönderilemez. smtplib bu durumda SMTPRecipientsRefused yükseltir. Adı “alıcılar reddedildi” demektir: gerçek bir sunucu alıcı adreslerinin hepsini reddettiğinde de aynı istisna gelir.

12. bölümde gördüğünüz gibi smtplib modülünün hata sınıfları SMTPException sınıfından, o da OSError sınıfından türer. ConnectionRefusedError da bir OSError türüdür, ama SMTPException değildir:

İstisna Ne zaman? SMTPException mı? OSError mı?
ConnectionRefusedError Sunucuya bağlanılamadı Hayır Evet
SMTPRecipientsRefused Bağlantı kuruldu, ama iletide alıcı yok ya da sunucu alıcıların hepsini reddetti Evet Evet

Aşağıdaki programı hata.py adıyla kaydedin. Sunucu açıkken ve kapalıyken ayrı ayrı çalıştırın. İki durumda hangi except bloğu çalışır?

import smtplib
from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "[email protected]"
msg["Subject"] = "Kulüp toplantısı"
msg.set_content("Toplantı yarın 14.00'te.")

try:
    with smtplib.SMTP("localhost", 8025) as smtp:
        smtp.send_message(msg)
except smtplib.SMTPException as error:
    print("Sunucu iletiyi kabul etmedi:", type(error).__name__)
except OSError as error:
    print("Sunucuya bağlanılamadı:", type(error).__name__)
else:
    print("İleti gönderildi.")

type(error).__name__ yakalanan istisnanın sınıf adını verir.

Sunucu açıkken:

Sunucu iletiyi kabul etmedi: SMTPRecipientsRefused

Sunucu kapalıyken:

Sunucuya bağlanılamadı: ConnectionRefusedError

except blokları yukarıdan aşağıya denenir ve türü uyan ilk blok çalışır. SMTPException bir OSError olduğu için iki bloğun yerini değiştirirseniz except OSError bloğu SMTP hatalarını da yakalar ve except smtplib.SMTPException bloğuna hiç sıra gelmez. Bu yüzden alt sınıfı önce yazarsınız. İki blok farklı durumları ayırır: SMTPException sunucuyla konuşulduğunu ama işin yürümediğini, öteki OSError türleri ise bağlantının hiç kurulamadığını söyler. Kullanıcıya da farklı bir şey söylersiniz: birinde “alıcı adresini kontrol edin”, ötekinde “sunucu çalışıyor mu, ona bakın”.

Kodu pakete ayırmak

Program büyüdükçe sınıfları 13. bölümdeki gibi bir pakete taşıyın:

eposta/
├── app.py
└── mailer/
    ├── __init__.py
    ├── message.py
    └── transport.py

__init__.py boş kalır. transport.py sunucu tarafını, yani sahte sunucuyu tutar:

# mailer/transport.py
from email.message import EmailMessage


class FakeSmtp:
    """İletileri göndermez, sent listesinde tutar."""

    def __init__(self) -> None:
        self.sent: list[EmailMessage] = []

    def send_message(self, msg: EmailMessage) -> None:
        self.sent.append(msg)

message.py iletiyi oluşturan sınıfı tutar. Main guard altındaki deneme kodu sahte sunucuyu kullanır, böylece modülü sunucu açmadan deneyebilirsiniz:

# mailer/message.py
from email.message import EmailMessage


class EmailNotification:
    def __init__(self, smtp, to: str) -> None:
        self.smtp = smtp
        self.to = to

    def send(self, message: str) -> str:
        msg = EmailMessage()
        msg["From"] = "[email protected]"
        msg["To"] = self.to
        msg["Subject"] = "Okul duyurusu"
        msg.set_content(message)
        self.smtp.send_message(msg)
        return f"E-posta: {message}"


if __name__ == "__main__":
    from .transport import FakeSmtp

    fake = FakeSmtp()
    EmailNotification(fake, "[email protected]").send("Deneme")
    print(fake.sent[0]["To"], fake.sent[0]["Subject"])

smtp parametresine tip ipucu yazılmadı, çünkü bu parametre tek bir sınıfa bağlı değil: hem FakeSmtp hem smtplib.SMTP nesnesi alır.

app.py paketin dışında durur ve gerçek sunucuyu kullanır:

# app.py
import smtplib

from mailer.message import EmailNotification


def main():
    with smtplib.SMTP("localhost", 8025) as smtp:
        notification = EmailNotification(smtp, "[email protected]")
        notification.send("Kulüp toplantısı yarın 14.00'te.")
    print("Duyuru gönderildi.")


if __name__ == "__main__":
    main()

Terminal eposta/ klasöründeyken iki komutu deneyin:

py -m mailer.message
py app.py

Birincisi sunucu kapalıyken de çalışır ve [email protected] Okul duyurusu yazar. İkincisi için sunucu açık olmalı. py mailer/message.py diye dosya yolunu verirseniz message.py paketin parçası olarak çalışmaz ve from .transport import FakeSmtp satırı ImportError: attempted relative import with no known parent package hatası verir. Göreli import içeren modülü py -m ile, paketin adıyla başlatın.

IMAP ile posta kutusunu okumak

Gönderme işinin karşı tarafı, sunucudaki kutuyu okumaktır. Python’da bunu imaplib modülü yapar. Kendi bilgisayarınızda IMAP denemesi için aiosmtpd gibi kolay kurulan bir sunucu yok, okumayı gerçek bir hesapla denemeniz gerekir. Bu yüzden kodu yalnız okuyun:

import imaplib
from email import message_from_bytes
from email.policy import default
from getpass import getpass

user = input("Kullanıcı adı: ")
password = getpass("Uygulama parolası: ")

with imaplib.IMAP4_SSL("imap.example.com") as imap:
    imap.login(user, password)
    imap.select("INBOX")
    status, data = imap.search(None, "UNSEEN")
    numbers = data[0].split()
    print(len(numbers), "okunmamış ileti var")

    for number in numbers[:5]:
        status, parts = imap.fetch(number, "(BODY.PEEK[HEADER])")
        msg = message_from_bytes(parts[0][1], policy=default)
        print(msg["From"], "|", msg["Subject"])
  • IMAP4_SSL("imap.example.com") sunucuya 993 numaralı porttan şifreli bağlanır. with bloğu bitince oturum kapanır.
  • login() kullanıcı adı ve parolayla oturum açar. select("INBOX") gelen kutusunu seçer.
  • imaplib çağrıları bir çift döndürür: işin sonucu ("OK" gibi) ve sunucudan gelen veri. search(None, "UNSEEN") okunmamış iletilerin numaralarını b"3 7 9" gibi tek bir bayt metni olarak verir. split() bunu [b"3", b"7", b"9"] listesine ayırır.
  • fetch() istenen iletinin istenen kısmını getirir. BODY.PEEK[HEADER] yalnız başlıkları ister. PEEK iletinin okundu diye işaretlenmesini önler.
  • message_from_bytes(..., policy=default) sunucudan gelen baytları bir EmailMessage nesnesine çevirir. Bu, gönderirken kullandığınız sınıfın aynısı: başlıkları yine msg["Subject"] ile okursunuz ve Türkçe harfler kodu çözülmüş olarak gelir.

Gerçek bir sunucuyla gönderim de benzer: bağlandıktan sonra smtp.starttls() bağlantıyı şifreler, smtp.login(user, password) oturum açar, sonra send_message() çağrılır. Sunucunun adını ve portunu e-posta hizmetinin yardım sayfası söyler:

with smtplib.SMTP("smtp.example.com", 587) as smtp:
    smtp.starttls()
    smtp.login(user, password)
    smtp.send_message(msg)
UyarıGerçek hesapla denemeden önce
  • Gmail ya da okul hesabıyla denemek için hesapta iki adımlı doğrulama açık olmalı. Programa hesabın asıl parolasını değil, hesap ayarlarından alacağınız bir uygulama parolası verirsiniz. Uygulama parolası yalnız bir program için oluşturulan ayrı bir paroladır, istediğiniz zaman iptal edebilirsiniz. Bazı kurumlar bu erişimi kapatır.
  • Parolayı koda yazmayın. Kodu bir arkadaşınıza gönderdiğinizde, internete yüklediğinizde ya da ekranı paylaştığınızda parola da gider. Yukarıdaki kod parolayı getpass() ile sorar: yazdığınız karakterler terminalde görünmez.
  • Denemede alıcı olarak yalnız kendi adresinizi kullanın.

Alıştırmalar

1. Konu başlığı parametresi

EmailNotification her iletiye Okul duyurusu konusunu yazıyor. Sınıfa subject parametresi ekleyin. Parametre verilmezse konu yine Okul duyurusu olsun. Hücre hata vermeden Kontroller tamamlandı yazarsa çözümünüz doğrudur.

class EmailNotification:
    def __init__(self, smtp, to, subject="Okul duyurusu"):
        self.smtp = smtp
        self.to = to
        self.subject = subject

    def send(self, message):
        msg = EmailMessage()
        msg["From"] = "[email protected]"
        msg["To"] = self.to
        msg["Subject"] = self.subject
        msg.set_content(message)
        self.smtp.send_message(msg)
        return f"E-posta: {message}"

2. Ekrana yazan sunucu

İletileri listede tutmak yerine ekrana yazan ConsoleSmtp sınıfını yazın. EmailNotification sınıfına dokunmayın. Hücreyi şimdi çalıştırırsanız hangi hatayı alırsınız? Sınıfı yazdıktan sonra hücre şunu yazmalı:

Kime: [email protected]
Konu: Okul duyurusu
Kulüp toplantısı yarın 14.00'te.

Boş sınıfla hücre AttributeError: 'ConsoleSmtp' object has no attribute 'send_message' verir. EmailNotification nesnenin sınıfına bakmaz, ama beklediği metot yoksa çağrı başarısız olur.

class ConsoleSmtp:
    def send_message(self, msg):
        print("Kime:", msg["To"])
        print("Konu:", msg["Subject"])
        print(msg.get_content().strip())

get_content() gövdenin sonundaki satır sonunu da döndürür. strip() onu siler.

3. Kullanıcıya anlaşılır mesaj

Bu alıştırmayı kendi bilgisayarınızda, “Kodu pakete ayırmak” başlığındaki eposta/ projesinde yapın. app.py dosyasındaki main() fonksiyonunu gönderim başarısız olunca program durmayacak biçimde değiştirin:

  • Sunucu kapalıysa Sunucuya bağlanılamadı. Sunucunun çalıştığını kontrol edin. yazsın.
  • Sunucu iletiyi kabul etmezse İleti gönderilemedi. Alıcı adresini kontrol edin. yazsın.
  • Gönderim başarılıysa yine Duyuru gönderildi. yazsın.

Üç durumu da deneyin: sunucu açıkken, sunucu kapalıyken ve sunucu açıkken alıcısız bir iletiyle. Alıcısız ileti için EmailNotification sınıfını değiştirmeyin: main() içinde To başlığı olmayan bir EmailMessage oluşturup doğrudan smtp.send_message() ile gönderin.

try bloğuna with smtplib.SMTP(...) satırını ve bloğun içini koyun. Önce except smtplib.SMTPException, sonra except OSError yazın. Başarı mesajı else bloğuna gider.

Başa dön