Kembali ke Artikel

Belajar Golang dari Nol #21: Mengirim Email dari Aplikasi Go

Solusi IT

Di Belajar Golang dari Nol #20 kita sudah bisa menerima upload file dari user. Aplikasi kita makin lengkap. User bisa daftar, login, kirim data, sampai unggah gambar. Tapi ada satu hal yang belum pernah benar-benar kita lakukan: menghubungi user balik. Kalau kamu baru bergabung di seri ini, cek dulu daftar lengkap seri supaya tidak lompat materi.

Di bagian 19 kita sempat menyimulasikan pengiriman email lewat worker. Waktu itu emailnya pura-pura. Cuma time.Sleep dan sebaris log. Sekarang kita ganti simulasi itu dengan pengiriman sungguhan.

Kenapa Aplikasi Perlu Mengirim Email

Email sering dianggap kuno. Kenyataannya, email tetap tulang punggung notifikasi bisnis. Beberapa kasus nyata yang hampir pasti kamu temui:

  • Notifikasi pesanan. User checkout, sistem kirim konfirmasi berisi nomor order dan total belanja. Tanpa ini, user bertanya-tanya apakah pesanannya masuk.
  • Reset password. User lupa password, sistem kirim link reset. Ini standar keamanan yang tidak bisa ditawar.
  • Laporan harian ke owner. Setiap jam 6 pagi, owner terima rekap penjualan kemarin di inbox. Tidak perlu buka dashboard.

WhatsApp dan push notification memang populer. Tapi email tidak butuh persetujuan platform, murah, dan punya jejak tertulis yang bisa diarsip. Untuk urusan transaksi, email masih juara.

Kenalan Singkat dengan SMTP

SMTP itu singkatan dari Simple Mail Transfer Protocol. Anggap saja dia kantor pos digital. Aplikasi kamu datang ke kantor pos (server SMTP), menyerahkan surat lengkap dengan alamat pengirim dan penerima, lalu kantor pos yang mengurus perjalanan surat itu sampai ke inbox tujuan. Kamu tidak perlu tahu rute detailnya. Yang kamu butuhkan cuma alamat kantor posnya (host dan port), plus identitas kamu (username dan password).

Nah, di sini banyak pemula tergoda memakai SMTP Gmail pribadi untuk aplikasi production. Jangan. Gmail pribadi punya limit kirim harian yang kecil, sekitar 500 email per hari. Lewat dari itu akun bisa diblokir sementara. Reputasi pengirimnya juga bukan milik kamu, dan Gmail mewajibkan app password yang sewaktu-waktu bisa dicabut kebijakannya. Untuk belajar dan eksperimen, silakan pakai. Untuk production, pakai layanan transactional email seperti Mailgun, Postmark, Resend, atau Amazon SES. Mereka memang dibuat untuk aplikasi: limit besar, ada dashboard delivery, dan reputasi terjaga. Alternatif lain adalah SMTP dari hosting sendiri, tapi sadari risikonya: kalau IP server kamu pernah dipakai spammer lain, email kamu ikut masuk spam.

Kabar baiknya, semua layanan itu bicara protokol yang sama. Kode Go yang kita tulis hari ini tinggal ganti host, port, dan kredensial.

Kirim Email Pertama dengan net/smtp

Go punya package bawaan net/smtp. Tidak perlu install apa-apa. Kredensial kita ambil dari environment variable, sesuai kebiasaan yang sudah kita bangun sejak bagian 15. Jangan pernah menulis password di kode.

package main

import (
	"fmt"
	"net/smtp"
	"os"
)

func main() {
	host := os.Getenv("SMTP_HOST") // contoh: smtp.gmail.com
	port := os.Getenv("SMTP_PORT") // contoh: 587
	user := os.Getenv("SMTP_USER")
	pass := os.Getenv("SMTP_PASS")

	from := "Toko Kita <noreply@tokokita.id>"
	to := []string{"budi@example.com"}

	msg := []byte("From: " + from + "\r\n" +
		"To: budi@example.com\r\n" +
		"Subject: Pesanan Kamu Sudah Kami Terima\r\n" +
		"MIME-Version: 1.0\r\n" +
		"Content-Type: text/plain; charset=\"UTF-8\"\r\n" +
		"\r\n" +
		"Halo Budi,\r\n\r\n" +
		"Pesanan kamu sudah masuk dan sedang kami proses.\r\n")

	auth := smtp.PlainAuth("", user, pass, host)

	err := smtp.SendMail(host+":"+port, auth, user, to, msg)
	if err != nil {
		fmt.Println("gagal kirim:", err)
		return
	}
	fmt.Println("email terkirim")
}

Ada dua hal yang perlu kamu perhatikan. Pertama, smtp.PlainAuth membungkus username dan password untuk autentikasi ke server. Parameter pertamanya identitas, biasanya dikosongkan. Kedua, format pesan itu bukan sekadar teks. Bagian atas adalah header: From, To, Subject, MIME-Version, dan Content-Type. Setiap baris header dipisah \r\n. Setelah header ada satu baris kosong, baru isi email. Kalau baris kosong itu hilang, email kamu tampil berantakan atau subject-nya hilang.

Jalankan dengan environment variable terpasang, lalu cek inbox tujuan. Kalau berhasil, selamat. Aplikasi kamu resmi bisa bicara ke dunia luar.

Email HTML dengan html/template

Email teks biasa cukup untuk notifikasi sederhana. Tapi email konfirmasi pesanan biasanya butuh format: nama tebal, tabel harga, tombol. Caranya, ganti Content-Type menjadi text/html dan isi body dengan HTML.

Untuk menyusun HTML-nya, jangan sambung string manual. Pakai html/template, package yang sama dengan yang pernah kita pakai untuk render halaman. Alasannya penting: html/template melakukan escaping otomatis. Kalau ada user iseng mendaftar dengan nama <script>alert(1)</script>, template akan mengubahnya jadi teks biasa yang aman, bukan kode yang dieksekusi.

package mailer

import (
	"bytes"
	"html/template"
)

type OrderEmailData struct {
	Nama       string
	NomorOrder string
	Total      string
}

var orderTpl = template.Must(template.New("order").Parse(`
<h2>Terima kasih, {{.Nama}}</h2>
<p>Pesanan <strong>{{.NomorOrder}}</strong> sudah kami terima.</p>
<p>Total pembayaran: <strong>{{.Total}}</strong></p>
<p>Kami kabari lagi begitu pesanan dikirim.</p>
`))

func RenderOrderEmail(data OrderEmailData) (string, error) {
	var buf bytes.Buffer
	if err := orderTpl.Execute(&buf, data); err != nil {
		return "", err
	}
	return buf.String(), nil
}

Struct OrderEmailData jadi kontrak yang jelas. Siapa pun yang mau kirim email pesanan tahu persis data apa yang harus disiapkan. template.Must membuat program langsung berhenti saat start kalau template-nya salah tulis, bukan meledak diam-diam saat ada order masuk.

Rapikan dengan Interface EmailSender

Sekarang bagian yang membedakan kode belajar dengan kode production. Ingat pelajaran interface di bagian 9? Kita definisikan kontrak pengirim email, lalu buat dua implementasi.

package mailer

import (
	"log"
	"net/smtp"
	"os"
)

type EmailSender interface {
	Send(to, subject, htmlBody string) error
}

// SMTPSender mengirim email sungguhan.
type SMTPSender struct {
	Host string
	Port string
	User string
	Pass string
	From string
}

func (s SMTPSender) Send(to, subject, htmlBody string) error {
	msg := []byte("From: " + s.From + "\r\n" +
		"To: " + to + "\r\n" +
		"Subject: " + subject + "\r\n" +
		"MIME-Version: 1.0\r\n" +
		"Content-Type: text/html; charset=\"UTF-8\"\r\n" +
		"\r\n" +
		htmlBody)

	auth := smtp.PlainAuth("", s.User, s.Pass, s.Host)
	return smtp.SendMail(s.Host+":"+s.Port, auth, s.User, []string{to}, msg)
}

// LogSender cuma mencatat, tidak mengirim. Untuk development.
type LogSender struct{}

func (LogSender) Send(to, subject, htmlBody string) error {
	log.Printf("[EMAIL-DEV] to=%s subject=%q panjang body=%d byte", to, subject, len(htmlBody))
	return nil
}

// NewSenderFromEnv memilih implementasi lewat env EMAIL_DRIVER.
func NewSenderFromEnv() EmailSender {
	if os.Getenv("EMAIL_DRIVER") == "smtp" {
		return SMTPSender{
			Host: os.Getenv("SMTP_HOST"),
			Port: os.Getenv("SMTP_PORT"),
			User: os.Getenv("SMTP_USER"),
			Pass: os.Getenv("SMTP_PASS"),
			From: os.Getenv("SMTP_FROM"),
		}
	}
	return LogSender{}
}

Kenapa repot begini? Karena saat development kamu tidak mau inbox pribadi kebanjiran email percobaan, dan tidak mau menghabiskan kuota layanan berbayar. Cukup set EMAIL_DRIVER selain smtp, semua email hanya tercatat di log. Di server production, set EMAIL_DRIVER=smtp dan email terkirim sungguhan. Kode aplikasi tidak berubah sama sekali.

Bonusnya untuk testing, seperti yang kita bahas di bagian 14: fungsi yang menerima EmailSender bisa diuji dengan implementasi palsu. Test kamu tidak pernah menyentuh server SMTP sungguhan.

Kirim Lewat Worker, Jangan Tahan Response

Mengirim email itu lambat. Bisa satu sampai lima detik, kadang lebih kalau server SMTP sedang sibuk. Kalau kamu kirim email di tengah handler HTTP, user menatap loading selama itu. Lebih buruk lagi, kalau SMTP error, apakah order user ikut gagal? Tidak masuk akal. Order sudah tersimpan di database, email cuma pelengkap.

Solusinya sudah kita bangun di bagian 19: worker. Handler cukup melempar job ke channel, lalu langsung membalas user. Worker di belakang layar yang mengurus pengiriman, lengkap dengan retry kalau gagal.

type EmailJob struct {
	To      string
	Subject string
	Body    string
}

func emailWorker(jobs <-chan EmailJob, sender mailer.EmailSender) {
	for job := range jobs {
		sendWithRetry(sender, job)
	}
}

func sendWithRetry(sender mailer.EmailSender, job EmailJob) {
	delays := []time.Duration{
		2 * time.Second,
		5 * time.Second,
		15 * time.Second,
	}

	var err error
	for percobaan := 1; percobaan <= len(delays)+1; percobaan++ {
		err = sender.Send(job.To, job.Subject, job.Body)
		if err == nil {
			log.Printf("email ke %s terkirim (percobaan %d)", job.To, percobaan)
			return
		}
		if percobaan <= len(delays) {
			log.Printf("email ke %s gagal (percobaan %d): %v, coba lagi", job.To, percobaan, err)
			time.Sleep(delays[percobaan-1])
		}
	}
	log.Printf("MENYERAH: email ke %s gagal total setelah %d percobaan: %v", job.To, len(delays)+1, err)
}

Pola retry-nya sederhana tapi lengkap. Jeda antar percobaan naik bertingkat: 2 detik, 5 detik, lalu 15 detik. Istilahnya backoff. Kalau server SMTP cuma tersendat sebentar, percobaan kedua biasanya berhasil. Kalau setelah empat kali tetap gagal, kita berhenti dan catat kegagalan final di log dengan jelas. Log inilah yang nanti kamu periksa saat ada user komplain tidak menerima email.

Jebakan yang Sering Menjebak Pemula

Email masuk spam. Ini keluhan nomor satu. Penyebabnya biasanya bukan kode, tapi DNS. Ada dua record yang wajib dikenal namanya: SPF dan DKIM. SPF mendeklarasikan server mana saja yang boleh mengirim email atas nama domain kamu. DKIM menandatangani email secara digital supaya penerima yakin isinya tidak dipalsukan. Keduanya diatur di pengaturan DNS domain dan panel penyedia email, bukan di kode Go. Layanan seperti Mailgun atau Postmark akan memberi kamu daftar record yang tinggal disalin ke DNS.

Alamat pengirim harus domain sendiri. Mengirim dari noreply@tokokita.id jauh lebih dipercaya daripada dari alamat Gmail gratisan. Selain soal reputasi, SPF dan DKIM memang hanya bisa dipasang di domain milik sendiri.

Jangan hardcode daftar penerima. Alamat owner untuk laporan harian, alamat admin untuk alert, semuanya taruh di environment variable atau database. Alamat email itu data, bukan kode. Kalau owner ganti alamat, kamu tidak seharusnya perlu build ulang aplikasi.

Latihan: Email Konfirmasi Order Lewat Worker

Mari rangkai semuanya. Endpoint order yang setelah sukses menyimpan data langsung melempar job email ke worker, memakai LogSender di development.

func main() {
	sender := mailer.NewSenderFromEnv()
	emailJobs := make(chan EmailJob, 100)
	go emailWorker(emailJobs, sender)

	http.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
		// ... validasi input dan simpan order ke database ...
		order := OrderEmailData{
			Nama:       "Budi",
			NomorOrder: "ORD-2026-0421",
			Total:      "Rp250.000",
		}

		body, err := mailer.RenderOrderEmail(order)
		if err != nil {
			log.Println("gagal render template:", err)
		} else {
			emailJobs <- EmailJob{
				To:      "budi@example.com",
				Subject: "Konfirmasi Pesanan " + order.NomorOrder,
				Body:    body,
			}
		}

		w.WriteHeader(http.StatusCreated)
		w.Write([]byte(`{"status":"order diterima"}`))
	})

	log.Println("server jalan di :8080")
	http.ListenAndServe(":8080", nil)
}

Perhatikan urutannya. Order disimpan dulu. Email dilempar ke channel. Response 201 Created langsung dikirim tanpa menunggu email selesai. Kalau render template gagal, kita catat dan tetap balas sukses, karena order-nya sendiri berhasil.

Jalankan tanpa EMAIL_DRIVER, lalu tembak endpoint-nya. Log yang muncul kira-kira begini:

2026/07/27 09.15.02 server jalan di :8080
2026/07/27 09.15.40 [EMAIL-DEV] to=budi@example.com subject="Konfirmasi Pesanan ORD-2026-0421" panjang body=218 byte
2026/07/27 09.15.40 email ke budi@example.com terkirim (percobaan 1)

Response ke user tetap kilat, email tercatat rapi, dan tidak ada satu pun email sungguhan yang keluar dari laptop kamu. Saat deploy, tinggal set EMAIL_DRIVER=smtp beserta kredensialnya. Coba kembangkan sendiri: tambah email reset password dengan template kedua, atau jadwalkan laporan harian memakai pola ticker dari bagian 19.

Penutup

Aplikasi kita sekarang bisa berbicara ke user lewat inbox mereka. Kamu sudah paham cara kerja SMTP, format pesan email, template HTML yang aman, pola EmailSender yang enak dites, sampai retry lewat worker. Fondasi notifikasi bisnis kamu sudah berdiri.

Di bagian 22 kita balik arah. Kalau hari ini aplikasi kita yang mengirim keluar, berikutnya kita belajar mengambil data dari layanan orang lain: “Terhubung ke API Pihak Ketiga: HTTP Client yang Benar”. Sampai ketemu di sana.

Butuh sistem dengan notifikasi email otomatis untuk bisnis kamu, dari konfirmasi order sampai laporan harian? Tim Arrazy siap bantu lewat jasa pengembangan sistem aplikasi.

BACA JUGA

Artikel Lainnya di Kategori Golang

Ingin Membaca Artikel Lainnya?

Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.

Lihat Semua Artikel