t0ul

Automate the Boring Stuff with Go, Ch. 17: Email and Notifications

Part of Automate the Boring Stuff with Go, a beginner-friendly guide to programming in Go.

New in the 2026 edition.

A program that finishes a job and tells nobody is a program you have to go and check on. This chapter is about the telling: email with the report attached, a message into the team chat, a notification on your own screen. All of it is in the standard library except the chat part, and the chat part is one HTTP POST. The code is under ch17.

Two rules before the code, because this is the chapter where programs start talking to other people. Credentials never go in source code or on the command line; they live in environment variables, and the sender here refuses to send a password over a connection that is not encrypted. And every program that sends something has a dry run, because the one time a loop emails three hundred members by mistake is the time you remember why.

What an email is

An email is text. A few header lines, a blank line, the body:

// an email is text: a few header lines, a blank line, the body
package main

import (
	"fmt"
	"strings"
	"time"
)

// PlainMessage builds a plain-text email in the format every mail server expects
// (RFC 5322). Lines end in \r\n, headers come first, one blank line separates the body.
func PlainMessage(from, to, subject, body string, now time.Time) []byte {
	var b strings.Builder
	fmt.Fprintf(&b, "From: %s\r\n", from)
	fmt.Fprintf(&b, "To: %s\r\n", to)
	fmt.Fprintf(&b, "Subject: %s\r\n", subject)
	fmt.Fprintf(&b, "Date: %s\r\n", now.Format(time.RFC1123Z))
	b.WriteString("MIME-Version: 1.0\r\n")
	b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
	b.WriteString("\r\n")
	b.WriteString(strings.ReplaceAll(body, "\n", "\r\n"))
	return []byte(b.String())
}

func main() {
	msg := PlainMessage("me@example.com", "you@example.com", "Lunch?", "Noon at the usual place.\nBring the report.", time.Now())
	fmt.Print(string(msg))
}
From: me@example.com
To: you@example.com
Subject: Lunch?
Date: Sat, 17 Oct 2026 11:32:47 -0400
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8

Noon at the usual place.
Bring the report.

Lines end in \r\n because the mail standard (RFC 5322) says so, and servers are picky. The Date header uses time.RFC1123Z, the one layout you will not have to look up.

Attachments

An attachment makes the message multipart: one part is the text, the next is the file, encoded in base64 so binary survives a system designed for text. mime/multipart writes the boundaries for you:

// a message with an attachment is a multipart message: text part, then the file in base64
package main

import (
	"bytes"
	"encoding/base64"
	"fmt"
	"io"
	"mime/multipart"
	"net/textproto"
	"os"
	"strings"
)

// WithAttachment builds a multipart/mixed message with one text part and one file.
func WithAttachment(from, to, subject, body, filename string, file []byte) ([]byte, error) {
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)

	fmt.Fprintf(&buf, "From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\n", from, to, subject)
	fmt.Fprintf(&buf, "Content-Type: multipart/mixed; boundary=%s\r\n\r\n", mw.Boundary())

	text, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=utf-8"}})
	if err != nil {
		return nil, err
	}
	io.WriteString(text, strings.ReplaceAll(body, "\n", "\r\n"))

	att, err := mw.CreatePart(textproto.MIMEHeader{
		"Content-Type":              {"application/octet-stream"},
		"Content-Transfer-Encoding": {"base64"},
		"Content-Disposition":       {fmt.Sprintf(`attachment; filename="%s"`, filename)},
	})
	if err != nil {
		return nil, err
	}
	enc := base64.NewEncoder(base64.StdEncoding, att)
	enc.Write(file)
	enc.Close()

	if err := mw.Close(); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

func main() {
	msg, err := WithAttachment("me@example.com", "you@example.com", "The report", "Attached as promised.", "report.csv", []byte("date,amount\n2026-10-17,42\n"))
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	fmt.Print(string(msg))
}
From: me@example.com
To: you@example.com
Subject: The report
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=67805107cb248a28978999f0b12ed0f4a44aab1f382772c3d09c33b3862a

--67805107cb248a28978999f0b12ed0f4a44aab1f382772c3d09c33b3862a
Content-Type: text/plain; charset=utf-8

Attached as promised.
--67805107cb248a28978999f0b12ed0f4a44aab1f382772c3d09c33b3862a
Content-Disposition: attachment; filename="report.csv"
...

Project: send a report

The first project sends a message with any number of attachments through any mail server: your provider’s SMTP server with an app password, or a relay on your network. The sending lives in its own file so the third project can share it:

package main

import (
	"bytes"
	"context"
	"crypto/tls"
	"encoding/base64"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"net"
	"net/smtp"
	"net/textproto"
	"os"
	"path/filepath"
	"strings"
	"time"
)

// Server is an outgoing mail server. User and Pass empty means no login (a local relay).
type Server struct {
	Host string
	Port int
	User string
	Pass string
}

// FromEnv reads SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASS.
func FromEnv() (Server, error) {
	s := Server{Host: os.Getenv("SMTP_HOST"), User: os.Getenv("SMTP_USER"), Pass: os.Getenv("SMTP_PASS"), Port: 587}
	if s.Host == "" {
		return s, fmt.Errorf("SMTP_HOST is not set")
	}
	if p := os.Getenv("SMTP_PORT"); p != "" {
		if _, err := fmt.Sscanf(p, "%d", &s.Port); err != nil {
			return s, fmt.Errorf("SMTP_PORT %q: %w", p, err)
		}
	}
	return s, nil
}

// Send delivers msg from one address to the recipients. It upgrades to TLS when the
// server offers STARTTLS, logs in when credentials are set, and refuses to send a
// password over a connection that is not encrypted.
func (s Server) Send(ctx context.Context, from string, to []string, msg []byte) error {
	addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port))
	conn, err := (&net.Dialer{Timeout: 15 * time.Second}).DialContext(ctx, "tcp", addr)
	if err != nil {
		return fmt.Errorf("connect %s: %w", addr, err)
	}
	c, err := smtp.NewClient(conn, s.Host)
	if err != nil {
		return fmt.Errorf("smtp handshake: %w", err)
	}
	defer c.Close()

	if ok, _ := c.Extension("STARTTLS"); ok {
		if err := c.StartTLS(&tls.Config{ServerName: s.Host}); err != nil {
			return fmt.Errorf("starttls: %w", err)
		}
	}
	if s.User != "" {
		if _, isTLS := c.TLSConnectionState(); !isTLS {
			return fmt.Errorf("refusing to send the password to %s without TLS", addr)
		}
		if err := c.Auth(smtp.PlainAuth("", s.User, s.Pass, s.Host)); err != nil {
			return fmt.Errorf("login as %s: %w", s.User, err)
		}
	}
	if err := c.Mail(from); err != nil {
		return fmt.Errorf("from %s: %w", from, err)
	}
	for _, rcpt := range to {
		if err := c.Rcpt(rcpt); err != nil {
			return fmt.Errorf("to %s: %w", rcpt, err)
		}
	}
	w, err := c.Data()
	if err != nil {
		return err
	}
	if _, err := w.Write(msg); err != nil {
		return err
	}
	if err := w.Close(); err != nil {
		return fmt.Errorf("server rejected the message: %w", err)
	}
	return c.Quit()
}

// BuildMessage assembles a message with an optional list of attachments.
func BuildMessage(from string, to []string, subject, body string, attachments []string, now time.Time) ([]byte, error) {
	var buf bytes.Buffer
	fmt.Fprintf(&buf, "From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\n",
		from, strings.Join(to, ", "), mime.QEncoding.Encode("utf-8", subject), now.Format(time.RFC1123Z))
	crlfBody := strings.ReplaceAll(body, "\n", "\r\n")

	if len(attachments) == 0 {
		buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
		buf.WriteString(crlfBody)
		return buf.Bytes(), nil
	}

	mw := multipart.NewWriter(&buf)
	fmt.Fprintf(&buf, "Content-Type: multipart/mixed; boundary=%s\r\n\r\n", mw.Boundary())
	text, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=utf-8"}})
	if err != nil {
		return nil, err
	}
	io.WriteString(text, crlfBody)
	for _, path := range attachments {
		data, err := os.ReadFile(path)
		if err != nil {
			return nil, fmt.Errorf("attachment: %w", err)
		}
		name := filepath.Base(path)
		part, err := mw.CreatePart(textproto.MIMEHeader{
			"Content-Type":              {mime.TypeByExtension(filepath.Ext(name))},
			"Content-Transfer-Encoding": {"base64"},
			"Content-Disposition":       {fmt.Sprintf(`attachment; filename="%s"`, name)},
		})
		if err != nil {
			return nil, err
		}
		enc := base64.NewEncoder(base64.StdEncoding, part)
		enc.Write(data)
		enc.Close()
	}
	if err := mw.Close(); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}
// send-report emails a message with attachments through any SMTP server. Server
// settings come from the environment (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS), never
// from the code or the command line, where they would end up in your shell history.
//
//	export SMTP_HOST=smtp.fastmail.com SMTP_PORT=587 SMTP_USER=me@example.com SMTP_PASS=app-password
//	go run ./ch17/projects/send-report -from me@example.com -to boss@example.com -subject "October report" -attach report.xlsx -body "Attached."
//	go run ./ch17/projects/send-report -dry-run -from a@b.c -to d@e.f -subject hi -body hello
package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"os"
	"strings"
	"time"
)

func run(args []string) error {
	flags := flag.NewFlagSet("send-report", flag.ContinueOnError)
	from := flags.String("from", "", "sender address")
	to := flags.String("to", "", "recipient addresses, comma separated")
	subject := flags.String("subject", "", "subject line")
	body := flags.String("body", "", "message text")
	attach := flags.String("attach", "", "files to attach, comma separated")
	dryRun := flags.Bool("dry-run", false, "print the message instead of sending it")
	if err := flags.Parse(args); err != nil {
		return err
	}
	if *from == "" || *to == "" || *subject == "" {
		return errors.New("usage: send-report -from A -to B[,C] -subject S [-body text] [-attach f1,f2] [-dry-run]")
	}
	recipients := strings.Split(*to, ",")
	var attachments []string
	if *attach != "" {
		attachments = strings.Split(*attach, ",")
	}
	msg, err := BuildMessage(*from, recipients, *subject, *body, attachments, time.Now())
	if err != nil {
		return err
	}
	if *dryRun {
		os.Stdout.Write(msg)
		return nil
	}
	server, err := FromEnv()
	if err != nil {
		return err
	}
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()
	if err := server.Send(ctx, *from, recipients, msg); err != nil {
		return err
	}
	fmt.Printf("sent %q to %s via %s (%d bytes)\n", *subject, *to, server.Host, len(msg))
	return nil
}

func main() {
	if err := run(os.Args[1:]); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}
$ go run ./ch17/projects/send-report -dry-run -from me@example.com -to you@example.com -subject "Lunch?" -body "Noon."
From: me@example.com
To: you@example.com
Subject: Lunch?
Date: Sat, 17 Oct 2026 11:32:49 -0400
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8

Noon.

The order inside Send is the whole lesson. Connect. Ask the server what it supports. If it offers STARTTLS, upgrade to an encrypted connection. Only then, and only if the connection really is encrypted, send the login. Then the envelope (MAIL FROM, RCPT TO), then the message, then QUIT. A program that skips the TLS check works fine right up until someone points it at the wrong port.

For a real provider, create an app password in its security settings rather than using your account password, and put the four SMTP_* variables in your shell profile or a secrets manager, never in the repo. The settings you need are the same four for every provider; two common ones:

  • Gmail: turn on 2-Step Verification, then Google Account, Security, App passwords; SMTP_HOST=smtp.gmail.com, SMTP_PORT=587, SMTP_USER is your full address.
  • Fastmail: Settings, Privacy and Security, Integrations, New app password; SMTP_HOST=smtp.fastmail.com, SMTP_PORT=587.

Nothing in this chapter needs an account until you drop -dry-run: the tests and the examples run against a server the test starts itself.

How it is tested

You cannot unit-test against a real mail provider, and you should not want to. The test next to the project starts a tiny SMTP server of its own, in process, on a random port, and the real Send delivers to it:

package main

import (
	"bufio"
	"net"
	"strings"
	"testing"
)

// Delivery is what the test server received for one message.
type Delivery struct {
	From string
	To   []string
	Data string
}

// startSMTP runs a minimal SMTP server on a random local port for the duration of the
// test. It speaks enough of the protocol for net/smtp to deliver a message, and it
// deliberately offers neither STARTTLS nor AUTH, like a local relay would.
func startSMTP(t *testing.T) (addr string, got chan Delivery) {
	t.Helper()
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ln.Close() })
	got = make(chan Delivery, 4)
	go func() {
		for {
			conn, err := ln.Accept()
			if err != nil {
				return
			}
			go serve(conn, got)
		}
	}()
	return ln.Addr().String(), got
}

func serve(conn net.Conn, got chan Delivery) {
	defer conn.Close()
	r := bufio.NewReader(conn)
	say := func(s string) { conn.Write([]byte(s + "\r\n")) }
	say("220 smtptest ESMTP")
	var d Delivery
	for {
		line, err := r.ReadString('\n')
		if err != nil {
			return
		}
		line = strings.TrimRight(line, "\r\n")
		cmd := strings.ToUpper(line)
		switch {
		case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
			say("250-smtptest")
			say("250 SIZE 10485760")
		case strings.HasPrefix(cmd, "MAIL FROM:"):
			d.From = strings.Trim(line[len("MAIL FROM:"):], "<> ")
			say("250 OK")
		case strings.HasPrefix(cmd, "RCPT TO:"):
			d.To = append(d.To, strings.Trim(line[len("RCPT TO:"):], "<> "))
			say("250 OK")
		case cmd == "DATA":
			say("354 End data with <CR><LF>.<CR><LF>")
			var body strings.Builder
			for {
				l, err := r.ReadString('\n')
				if err != nil {
					return
				}
				if l == ".\r\n" {
					break
				}
				body.WriteString(l)
			}
			d.Data = body.String()
			got <- d
			d = Delivery{}
			say("250 OK queued")
		case cmd == "QUIT":
			say("221 Bye")
			return
		default:
			say("250 OK")
		}
	}
}

Forty lines of protocol are enough for net/smtp to hold a complete conversation, and the test then reads the delivered message and checks the attachment is in it. A third test gives the server no TLS and the client a password, and asserts that Send refuses. That refusal is the most important line in the file, and now it cannot quietly disappear.

Project: notify

The second project is the last line of every scheduled job: a message to a chat webhook, a desktop notification, or both, and always stdout so the log has it too.

// notify gets a message to you: a chat webhook (Slack, Discord, Mattermost and friends
// all accept a small JSON POST), a desktop notification, or plain stdout. It is the
// last line of every scheduled job: "done, 1284 files, 3 errors".
//
//	go run ./ch17/projects/notify -desktop "backup finished"
//	NOTIFY_WEBHOOK=https://hooks.slack.com/services/... go run ./ch17/projects/notify "backup finished"
//	go run ./ch13/projects/every -interval 1h -- sh -c 'go run ./ch11/projects/download-xkcd -n 1 && go run ./ch17/projects/notify "xkcd fetched"'
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"net/http"
	"os"
	"os/exec"
	"runtime"
	"strings"
	"time"
)

var client = &http.Client{Timeout: 15 * time.Second}

// Payload builds the JSON body a chat webhook expects. Slack-style hooks read "text",
// Discord reads "content"; sending both keeps one program working with either.
func Payload(message string) []byte {
	b, _ := json.Marshal(map[string]string{"text": message, "content": message})
	return b
}

// PostWebhook delivers the message and treats any non-2xx reply as a failure.
func PostWebhook(ctx context.Context, url, message string) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(Payload(message)))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("webhook: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		return fmt.Errorf("webhook: %s", resp.Status)
	}
	return nil
}

// DesktopCommand returns the operating system's notification command, or nil.
func DesktopCommand(goos, title, message string) *exec.Cmd {
	switch goos {
	case "darwin":
		script := fmt.Sprintf(`display notification %q with title %q`, message, title)
		return exec.Command("osascript", "-e", script)
	case "linux":
		return exec.Command("notify-send", title, message)
	case "windows":
		ps := fmt.Sprintf(`[void][System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); `+
			`$n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; `+
			`$n.Visible = $true; $n.ShowBalloonTip(5000, %q, %q, 'Info')`, title, message)
		return exec.Command("powershell", "-NoProfile", "-Command", ps)
	}
	return nil
}

func run(args []string) error {
	flags := flag.NewFlagSet("notify", flag.ContinueOnError)
	webhook := flags.String("webhook", os.Getenv("NOTIFY_WEBHOOK"), "chat webhook URL (default $NOTIFY_WEBHOOK)")
	desktop := flags.Bool("desktop", false, "also show a desktop notification")
	title := flags.String("title", "notify", "title for the desktop notification")
	if err := flags.Parse(args); err != nil {
		return err
	}
	if flags.NArg() == 0 {
		return errors.New("usage: notify [-webhook URL] [-desktop] [-title T] <message>")
	}
	message := strings.Join(flags.Args(), " ")

	var errs []error
	delivered := 0
	if *webhook != "" {
		ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
		defer cancel()
		if err := PostWebhook(ctx, *webhook, message); err != nil {
			errs = append(errs, err)
		} else {
			delivered++
		}
	}
	if *desktop {
		if cmd := DesktopCommand(runtime.GOOS, *title, message); cmd != nil {
			if err := cmd.Run(); err != nil {
				errs = append(errs, fmt.Errorf("desktop: %w", err))
			} else {
				delivered++
			}
		}
	}
	fmt.Println(message) // stdout always, so a log captures it too
	if delivered == 0 && len(errs) > 0 {
		return errors.Join(errs...)
	}
	for _, e := range errs {
		fmt.Fprintln(os.Stderr, "warning:", e)
	}
	return nil
}

func main() {
	if err := run(os.Args[1:]); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

Slack, Discord, Mattermost, Teams and most chat tools give you a webhook URL: POST a small JSON document to it and a message appears in a channel. The payload sends both text (Slack-style) and content (Discord-style) so one program works with either. Getting one takes a minute: in Slack, add the “Incoming Webhooks” app to your workspace and pick a channel; in Discord, open a channel’s settings, Integrations, Webhooks, New Webhook, Copy URL. The URL is a secret, because anyone who has it can post as you, so it comes from NOTIFY_WEBHOOK rather than the command line.

Text messages to a phone work the same way: SMS providers expose an HTTP API that takes a POST with a token. It is the webhook pattern with a different URL and an Authorization header, which is why there is no separate project for it.

The desktop notification is osascript on macOS, notify-send on Linux and a few lines of PowerShell on Windows, chosen with runtime.GOOS like the alarm in chapter 13. osascript and PowerShell are always present; notify-send is in the libnotify-bin package on Debian and Ubuntu (libnotify on Fedora) and comes with most desktop installs.

$ go run ./ch13/projects/every -interval 1h -- sh -c 'go run ./ch11/projects/download-xkcd -n 1 && go run ./ch17/projects/notify -desktop "xkcd fetched"'

Project: dues reminders

The third project is the classic office automation from the Python original: a membership spreadsheet with a column per month, and an email to everyone who has not paid the latest one. It reuses chapter 9’s excelize and this chapter’s mail.go.

// dues-reminders reads a membership workbook (one row per member, one column per month,
// "paid" where dues came in) and emails everyone who has not paid the latest month.
// It prints what it would send; add -send to actually send through the SMTP server in
// the environment (see send-report).
//
//	go run ./ch17/projects/dues-reminders ./ch17/projects/dues-reminders/members.xlsx
//	go run ./ch17/projects/dues-reminders -send -from treasurer@club.org ./ch17/projects/dues-reminders/members.xlsx
package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"os"
	"strings"
	"time"

	"github.com/xuri/excelize/v2"
)

// Member is a row of the sheet.
type Member struct {
	Name  string
	Email string
}

// Unpaid finds the last month column that has any entry and returns the members whose
// cell in that column is not "paid". Columns: Name, Email, then months in order.
func Unpaid(rows [][]string) (month string, members []Member, err error) {
	if len(rows) < 2 || len(rows[0]) < 3 {
		return "", nil, errors.New("need a header row with Name, Email and at least one month")
	}
	header := rows[0]
	latest := -1
	for col := len(header) - 1; col >= 2 && latest < 0; col-- {
		for _, row := range rows[1:] {
			if col < len(row) && strings.TrimSpace(row[col]) != "" {
				latest = col
				break
			}
		}
	}
	if latest < 0 {
		return "", nil, errors.New("no month has any payments recorded")
	}
	for _, row := range rows[1:] {
		if len(row) < 2 || row[0] == "" {
			continue
		}
		paid := latest < len(row) && strings.EqualFold(strings.TrimSpace(row[latest]), "paid")
		if !paid {
			members = append(members, Member{Name: row[0], Email: row[1]})
		}
	}
	return header[latest], members, nil
}

// Reminder is the message text for one member.
func Reminder(m Member, month string) (subject, body string) {
	subject = fmt.Sprintf("%s dues unpaid", month)
	body = fmt.Sprintf("Dear %s,\n\nRecords show that you have not paid dues for %s. Please make this payment as soon as possible.\n\nThank you.", m.Name, month)
	return subject, body
}

func run(args []string) error {
	flags := flag.NewFlagSet("dues-reminders", flag.ContinueOnError)
	send := flags.Bool("send", false, "send the emails (default: print them)")
	from := flags.String("from", "", "sender address (required with -send)")
	if err := flags.Parse(args); err != nil {
		return err
	}
	if flags.NArg() != 1 || (*send && *from == "") {
		return errors.New("usage: dues-reminders [-send -from you@example.com] <members.xlsx>")
	}
	f, err := excelize.OpenFile(flags.Arg(0))
	if err != nil {
		return fmt.Errorf("open: %w", err)
	}
	defer f.Close()
	rows, err := f.GetRows(f.GetSheetList()[0])
	if err != nil {
		return err
	}
	month, members, err := Unpaid(rows)
	if err != nil {
		return err
	}
	fmt.Printf("%d member(s) unpaid for %s\n", len(members), month)

	var server Server
	if *send {
		if server, err = FromEnv(); err != nil {
			return err
		}
	}
	for _, m := range members {
		subject, body := Reminder(m, month)
		if !*send {
			fmt.Printf("\n--- would send to %s <%s>\nSubject: %s\n%s\n", m.Name, m.Email, subject, body)
			continue
		}
		msg, err := BuildMessage(*from, []string{m.Email}, subject, body, nil, time.Now())
		if err != nil {
			return err
		}
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		err = server.Send(ctx, *from, []string{m.Email}, msg)
		cancel()
		if err != nil {
			return fmt.Errorf("%s: %w", m.Email, err)
		}
		fmt.Printf("sent to %s\n", m.Email)
	}
	return nil
}

func main() {
	if err := run(os.Args[1:]); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}
$ go run ./ch17/projects/dues-reminders ./ch17/projects/dues-reminders/members.xlsx
3 member(s) unpaid for Mar

--- would send to Grace Hopper <grace@example.com>
Subject: Mar dues unpaid
Dear Grace Hopper,

Records show that you have not paid dues for Mar. Please make this payment as soon as possible.

Thank you.

--- would send to Linus Torvalds <linus@example.com>
...

Unpaid finds the latest month by looking for the right-most column with anything in it, which is how a treasurer thinks about the sheet: the current month is the one being filled in. Without -send the program prints what it would do, and the test for the sending path delivers every reminder into the same in-process SMTP server and reads them back.

Conclusion

Build the message as text, send it over an encrypted SMTP connection with credentials from the environment, or POST a line of JSON to a webhook. Dry run first. Test against a server you run yourself. With those habits every program in this book can report in when it is done.

← Back to the blog