package main import ( "bufio" "bytes" "crypto/tls" "errors" "fmt" "io" "net/http" "net/http/cookiejar" "net/smtp" "net/textproto" "net/url" "os" "path" "slices" "strconv" "strings" "time" "golang.org/x/net/html" ) const ( defaultStartDate = "2026-01-20" defaultUpcomingDays = 14 userAgent = "alma-assignments-reporter/1.0" ) type Config struct { AssignmentsURL string ScheduleURL string StartDate time.Time UpcomingDays int AlmaCredsFile string SMTPHost string SMTPPort int SMTPUsername string SMTPPassword string SMTPStartTLS bool EmailFrom string EmailTo []string PrintReport bool SendEmail bool } type Assignment struct { DueDate time.Time Course string Title string Status string Link string Category string } func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } func run() error { cfg, err := loadConfig() if err != nil { return err } creds, err := loadAlmaCreds(cfg.AlmaCredsFile) if err != nil { return err } client, err := newHTTPClient() if err != nil { return err } if err := login(client, cfg.AssignmentsURL, creds["username"], creds["password"]); err != nil { return err } now := time.Now() missing, err := fetchMissingAssignments(client, cfg.AssignmentsURL, now) if err != nil { return err } missing = filterMissingAssignments(missing, cfg.StartDate) classLinks, err := fetchClassLinks(client, cfg.ScheduleURL) if err != nil { return err } var upcoming []Assignment for _, classURL := range classLinks { items, err := fetchUpcomingAssignmentsForClass(client, classURL, now) if err != nil { return err } upcoming = append(upcoming, items...) } upcoming = dedupeAssignments(upcoming) upcoming = filterUpcomingAssignments(upcoming, now, cfg.UpcomingDays) subject := buildSubject(missing, upcoming, cfg.StartDate, cfg.UpcomingDays) body := buildBody(missing, upcoming, cfg.StartDate, cfg.AssignmentsURL, cfg.ScheduleURL, now, cfg.UpcomingDays) if cfg.PrintReport { fmt.Println(subject) fmt.Println() fmt.Print(body) } if cfg.SendEmail { if err := sendEmail(cfg, subject, body); err != nil { return err } } return nil } func loadConfig() (Config, error) { assignmentsURL, err := readValue("ALMA_ASSIGNMENTS_URL", "", true) if err != nil { return Config{}, err } scheduleURL, err := readValue("ALMA_SCHEDULE_URL", deriveScheduleURL(assignmentsURL), true) if err != nil { return Config{}, err } startDateText, err := readValue("ALMA_START_DATE", defaultStartDate, true) if err != nil { return Config{}, err } startDate, err := time.Parse("2006-01-02", startDateText) if err != nil { return Config{}, fmt.Errorf("parse ALMA_START_DATE: %w", err) } upcomingDaysText, err := readValue("ALMA_UPCOMING_DAYS", strconv.Itoa(defaultUpcomingDays), true) if err != nil { return Config{}, err } upcomingDays, err := strconv.Atoi(upcomingDaysText) if err != nil { return Config{}, fmt.Errorf("parse ALMA_UPCOMING_DAYS: %w", err) } almaCredsFile, err := readValue("ALMA_CREDS_FILE", "/config/alma.creds", false) if err != nil { return Config{}, err } smtpHost, err := readValue("SMTP_HOST", "", false) if err != nil { return Config{}, err } smtpPortText, err := readValue("SMTP_PORT", "587", false) if err != nil { return Config{}, err } smtpPort, err := strconv.Atoi(smtpPortText) if err != nil { return Config{}, fmt.Errorf("parse SMTP_PORT: %w", err) } smtpUsername, err := readValue("SMTP_USERNAME", "", false) if err != nil { return Config{}, err } smtpPassword, err := readValue("SMTP_PASSWORD", "", false) if err != nil { return Config{}, err } smtpStartTLS := readBool("SMTP_STARTTLS", true) emailFrom, err := readValue("EMAIL_FROM", "", false) if err != nil { return Config{}, err } emailToText, err := readValue("EMAIL_TO", "", false) if err != nil { return Config{}, err } emailTo := splitCSV(emailToText) printReport := readBool("PRINT_REPORT", false) sendEmailFlag := readBool("SEND_EMAIL", true) if sendEmailFlag { if smtpHost == "" || emailFrom == "" || len(emailTo) == 0 { return Config{}, errors.New("SMTP_HOST, EMAIL_FROM, and EMAIL_TO are required when SEND_EMAIL is true") } if smtpUsername != "" && smtpPassword == "" { return Config{}, errors.New("SMTP_PASSWORD is required when SMTP_USERNAME is set") } } return Config{ AssignmentsURL: assignmentsURL, ScheduleURL: scheduleURL, StartDate: startDate, UpcomingDays: upcomingDays, AlmaCredsFile: almaCredsFile, SMTPHost: smtpHost, SMTPPort: smtpPort, SMTPUsername: smtpUsername, SMTPPassword: smtpPassword, SMTPStartTLS: smtpStartTLS, EmailFrom: emailFrom, EmailTo: emailTo, PrintReport: printReport, SendEmail: sendEmailFlag, }, nil } func deriveScheduleURL(assignmentsURL string) string { parsed, err := url.Parse(assignmentsURL) if err != nil { return assignmentsURL } parsed.Path = path.Dir(parsed.Path) + "/schedule" parsed.RawQuery = "" parsed.Fragment = "" return parsed.String() } func readValue(name, fallback string, required bool) (string, error) { if value := strings.TrimSpace(os.Getenv(name)); value != "" { return value, nil } if filePath := strings.TrimSpace(os.Getenv(name + "_FILE")); filePath != "" { data, err := os.ReadFile(filePath) if err != nil { return "", fmt.Errorf("read %s_FILE: %w", name, err) } return strings.TrimSpace(string(data)), nil } if fallback != "" { return fallback, nil } if required { return "", fmt.Errorf("missing required setting: %s or %s_FILE", name, name) } return "", nil } func readBool(name string, fallback bool) bool { value := strings.TrimSpace(strings.ToLower(os.Getenv(name))) if value == "" { return fallback } switch value { case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: return fallback } } func splitCSV(value string) []string { var items []string for _, part := range strings.Split(value, ",") { part = strings.TrimSpace(part) if part != "" { items = append(items, part) } } return items } func loadAlmaCreds(filePath string) (map[string]string, error) { if envUser, _ := readValue("ALMA_USERNAME", "", false); envUser != "" { if envPass, _ := readValue("ALMA_PASSWORD", "", false); envPass != "" { return map[string]string{ "username": envUser, "password": envPass, }, nil } return nil, errors.New("ALMA_PASSWORD is required when ALMA_USERNAME is set") } if filePath == "" { return nil, errors.New("missing Alma credentials: set ALMA_USERNAME/ALMA_PASSWORD or ALMA_CREDS_FILE") } data, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("read alma creds: %w", err) } values := map[string]string{} scanner := bufio.NewScanner(bytes.NewReader(data)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } key, value, ok := strings.Cut(line, ":") if !ok { continue } values[strings.TrimSpace(key)] = strings.TrimSpace(value) } if err := scanner.Err(); err != nil { return nil, err } if values["username"] == "" || values["password"] == "" { return nil, errors.New("alma creds file must contain username and password") } return values, nil } func newHTTPClient() (*http.Client, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err } return &http.Client{ Jar: jar, Timeout: 30 * time.Second, }, nil } func login(client *http.Client, assignmentsURL, username, password string) error { initialReq, err := http.NewRequest(http.MethodGet, assignmentsURL, nil) if err != nil { return err } initialReq.Header.Set("User-Agent", userAgent) initialResp, err := client.Do(initialReq) if err != nil { return err } defer initialResp.Body.Close() if initialResp.StatusCode >= 400 { return fmt.Errorf("alma initial request failed: %s", initialResp.Status) } parsedURL, err := url.Parse(assignmentsURL) if err != nil { return err } form := url.Values{ "username": []string{username}, "password": []string{password}, "_done": []string{parsedURL.Path}, "deviceKey": []string{""}, } loginURL := parsedURL.ResolveReference(&url.URL{Path: "/login"}) req, err := http.NewRequest(http.MethodPost, loginURL.String(), strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", userAgent) resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode >= 400 { return fmt.Errorf("alma login failed: %s", resp.Status) } if strings.Contains(resp.Request.URL.Path, "/login") || !bytes.Contains(bytes.ToLower(body), []byte("logout")) { return errors.New("alma login failed") } return nil } func fetchMissingAssignments(client *http.Client, assignmentsURL string, now time.Time) ([]Assignment, error) { root, baseURL, err := fetchHTML(client, assignmentsURL) if err != nil { return nil, err } table := findNode(root, func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "missing-assignments") }) if table == nil { return nil, errors.New("missing assignments table not found") } var assignments []Assignment for _, row := range findNodes(table, func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "tr" }) { cells := directChildrenByTag(row, "td") if len(cells) < 3 { continue } courseLink := findNode(cells[1], func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "a" && parentIsTag(n, "small") }) assignmentLink := findNode(cells[1], func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "a" && attr(n, "data-alma-modal") != "" }) if courseLink == nil || assignmentLink == nil { continue } assignments = append(assignments, Assignment{ DueDate: inferAssignmentDate(nodeText(cells[0]), now), Course: nodeText(courseLink), Title: nodeText(assignmentLink), Status: firstNonEmpty(attr(findNode(cells[2], func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "i" && attr(n, "title") != "" }), "title"), nodeText(cells[2]), "Unknown"), Link: resolveURL(baseURL, attr(assignmentLink, "href")), }) } sortAssignments(assignments) return assignments, nil } func fetchClassLinks(client *http.Client, scheduleURL string) ([]string, error) { root, baseURL, err := fetchHTML(client, scheduleURL) if err != nil { return nil, err } seen := map[string]struct{}{} var links []string for _, link := range findNodes(root, func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "a" && strings.Contains(attr(n, "href"), "/class/") && attr(n, "data-track") == "Class Home Page" }) { href := attr(link, "href") if href == "" { continue } classURL := resolveURL(baseURL, strings.SplitN(href, "?", 2)[0]) if _, ok := seen[classURL]; ok { continue } seen[classURL] = struct{}{} links = append(links, classURL) } slices.Sort(links) return links, nil } func fetchUpcomingAssignmentsForClass(client *http.Client, classURL string, now time.Time) ([]Assignment, error) { root, baseURL, err := fetchHTML(client, classURL) if err != nil { return nil, err } table := findNode(root, func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "upcoming-assignments") }) if table == nil { return nil, nil } course := nodeText(findNode(root, func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "h3" && ancestorHasClass(n, "class-header") })) var assignments []Assignment for _, row := range findNodes(table, func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "tr" }) { cells := directChildrenByTag(row, "td") if len(cells) < 2 { continue } link := findNode(cells[1], func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "a" && attr(n, "data-alma-modal") != "" }) if link == nil { continue } assignments = append(assignments, Assignment{ DueDate: inferUpcomingDate(nodeText(cells[0]), now), Course: course, Title: nodeText(link), Status: "Upcoming", Link: resolveURL(baseURL, attr(link, "href")), Category: nodeText(findNode(cells[1], func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "small" })), }) } sortAssignments(assignments) return assignments, nil } func fetchHTML(client *http.Client, rawURL string) (*html.Node, *url.URL, error) { req, err := http.NewRequest(http.MethodGet, rawURL, nil) if err != nil { return nil, nil, err } req.Header.Set("User-Agent", userAgent) resp, err := client.Do(req) if err != nil { return nil, nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, nil, fmt.Errorf("fetch %s: %s", rawURL, resp.Status) } doc, err := html.Parse(resp.Body) if err != nil { return nil, nil, err } return doc, resp.Request.URL, nil } func filterMissingAssignments(assignments []Assignment, startDate time.Time) []Assignment { var filtered []Assignment for _, assignment := range assignments { if !assignment.DueDate.Before(startOfDay(startDate)) { filtered = append(filtered, assignment) } } return filtered } func filterUpcomingAssignments(assignments []Assignment, now time.Time, days int) []Assignment { start := startOfDay(now) end := start.AddDate(0, 0, days) var filtered []Assignment for _, assignment := range assignments { if !assignment.DueDate.Before(start) && !assignment.DueDate.After(end) { filtered = append(filtered, assignment) } } return filtered } func dedupeAssignments(assignments []Assignment) []Assignment { seen := map[string]struct{}{} var deduped []Assignment for _, assignment := range assignments { key := assignment.Link + "|" + assignment.Title + "|" + assignment.DueDate.Format("2006-01-02") if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} deduped = append(deduped, assignment) } sortAssignments(deduped) return deduped } func buildSubject(missing, upcoming []Assignment, startDate time.Time, upcomingDays int) string { return fmt.Sprintf( "Alma report: %d missing since %s, %d due in %d days", len(missing), startDate.Format("2006-01-02"), len(upcoming), upcomingDays, ) } func buildBody(missing, upcoming []Assignment, startDate time.Time, assignmentsURL, scheduleURL string, now time.Time, upcomingDays int) string { var b strings.Builder fmt.Fprintf(&b, "Daily Alma missing assignments report for %s\n", now.Format("2006-01-02")) fmt.Fprintf(&b, "Assignments due on or after %s\n\n", startDate.Format("2006-01-02")) b.WriteString("Missing / Revision Needed\n") b.WriteString("-------------------------\n") if len(missing) == 0 { b.WriteString("No missing or revision-needed assignments matched the filter.\n") } else { for _, assignment := range missing { fmt.Fprintf(&b, "- %s | %s | %s\n", assignment.DueDate.Format("2006-01-02"), assignment.Course, assignment.Title) fmt.Fprintf(&b, " Status: %s\n", assignment.Status) fmt.Fprintf(&b, " Link: %s\n", assignment.Link) } } fmt.Fprintf(&b, "\nUpcoming In Next %d Days\n", upcomingDays) b.WriteString("------------------------\n") if len(upcoming) == 0 { b.WriteString("No upcoming assignments were due in the configured window.\n") } else { for _, assignment := range upcoming { fmt.Fprintf(&b, "- %s | %s | %s\n", assignment.DueDate.Format("2006-01-02"), assignment.Course, assignment.Title) fmt.Fprintf(&b, " Category: %s\n", firstNonEmpty(assignment.Category, "Upcoming")) fmt.Fprintf(&b, " Link: %s\n", assignment.Link) } } fmt.Fprintf(&b, "\nAssignments page: %s\n", assignmentsURL) fmt.Fprintf(&b, "Schedule page: %s\n", scheduleURL) return b.String() } func sendEmail(cfg Config, subject, body string) error { address := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort) conn, err := smtp.Dial(address) if err != nil { return err } defer conn.Close() host := cfg.SMTPHost if cfg.SMTPStartTLS { if ok, _ := conn.Extension("STARTTLS"); ok { if err := conn.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { return err } } } if cfg.SMTPUsername != "" { auth := smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, host) if err := conn.Auth(auth); err != nil { return err } } if err := conn.Mail(cfg.EmailFrom); err != nil { return err } for _, recipient := range cfg.EmailTo { if err := conn.Rcpt(recipient); err != nil { return err } } writer, err := conn.Data() if err != nil { return err } defer writer.Close() headers := textproto.MIMEHeader{} headers.Set("From", cfg.EmailFrom) headers.Set("To", strings.Join(cfg.EmailTo, ", ")) headers.Set("Subject", subject) headers.Set("MIME-Version", "1.0") headers.Set("Content-Type", "text/plain; charset=UTF-8") for key, values := range headers { for _, value := range values { if _, err := fmt.Fprintf(writer, "%s: %s\r\n", key, value); err != nil { return err } } } if _, err := io.WriteString(writer, "\r\n"+body); err != nil { return err } return conn.Quit() } func inferAssignmentDate(monthDay string, now time.Time) time.Time { return inferDate(monthDay, now, false) } func inferUpcomingDate(monthDay string, now time.Time) time.Time { return inferDate(monthDay, now, true) } func inferDate(monthDay string, now time.Time, future bool) time.Time { candidate, err := time.Parse("Jan 02 2006", monthDay+" "+strconv.Itoa(now.Year())) if err != nil { return time.Time{} } candidate = startOfDay(candidate) today := startOfDay(now) if future { if candidate.Before(today.AddDate(0, 0, -30)) { return candidate.AddDate(1, 0, 0) } return candidate } if candidate.After(today.AddDate(0, 0, 30)) { return candidate.AddDate(-1, 0, 0) } return candidate } func startOfDay(t time.Time) time.Time { year, month, day := t.Date() return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) } func sortAssignments(assignments []Assignment) { slices.SortFunc(assignments, func(a, b Assignment) int { if !a.DueDate.Equal(b.DueDate) { if a.DueDate.Before(b.DueDate) { return -1 } return 1 } if c := strings.Compare(strings.ToLower(a.Course), strings.ToLower(b.Course)); c != 0 { return c } return strings.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title)) }) } func resolveURL(baseURL *url.URL, href string) string { if baseURL == nil || href == "" { return "" } ref, err := url.Parse(href) if err != nil { return "" } return baseURL.ResolveReference(ref).String() } func findNode(root *html.Node, match func(*html.Node) bool) *html.Node { if root == nil { return nil } if match(root) { return root } for child := root.FirstChild; child != nil; child = child.NextSibling { if found := findNode(child, match); found != nil { return found } } return nil } func findNodes(root *html.Node, match func(*html.Node) bool) []*html.Node { var nodes []*html.Node var walk func(*html.Node) walk = func(n *html.Node) { if n == nil { return } if match(n) { nodes = append(nodes, n) } for child := n.FirstChild; child != nil; child = child.NextSibling { walk(child) } } walk(root) return nodes } func directChildrenByTag(node *html.Node, tag string) []*html.Node { var children []*html.Node for child := node.FirstChild; child != nil; child = child.NextSibling { if child.Type == html.ElementNode && child.Data == tag { children = append(children, child) } } return children } func hasClass(node *html.Node, class string) bool { for _, item := range strings.Fields(attr(node, "class")) { if item == class { return true } } return false } func ancestorHasClass(node *html.Node, class string) bool { for current := node.Parent; current != nil; current = current.Parent { if hasClass(current, class) { return true } } return false } func parentIsTag(node *html.Node, tag string) bool { return node != nil && node.Parent != nil && node.Parent.Type == html.ElementNode && node.Parent.Data == tag } func attr(node *html.Node, key string) string { if node == nil { return "" } for _, attribute := range node.Attr { if attribute.Key == key { return attribute.Val } } return "" } func nodeText(node *html.Node) string { if node == nil { return "" } var parts []string var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.TextNode { if text := strings.TrimSpace(n.Data); text != "" { parts = append(parts, text) } } for child := n.FirstChild; child != nil; child = child.NextSibling { walk(child) } } walk(node) return strings.Join(parts, " ") } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" }