// Package vcard implements a minimal RFC 6350 vCard parser/builder โ€” just // the fields CardDAV needs to round-trip contacts: UID, FN, N, EMAIL, TEL, // ORG, NOTE. Not a full vCard 4.0 implementation (no PHOTO, no groups, no // extended params) โ€” enough for real mail/contacts clients to store and // retrieve a usable contact, with more fields added as client compatibility // testing surfaces the need. package vcard import ( "fmt" "strings" ) type Card struct { UID string FN string // formatted name N string // structured name: Family;Given;Middle;Prefix;Suffix Email []string Tel []string Org string Note string } // Parse reads a single vCard (BEGIN:VCARD...END:VCARD) into a Card. func Parse(data string) (*Card, error) { lines := unfold(data) c := &Card{} inCard := false for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } upper := strings.ToUpper(line) switch { case upper == "BEGIN:VCARD": inCard = true continue case upper == "END:VCARD": inCard = false continue } if !inCard { continue } name, value, found := splitProperty(line) if !found { continue } switch strings.ToUpper(name) { case "UID": c.UID = value case "FN": c.FN = value case "N": c.N = value case "EMAIL": c.Email = append(c.Email, value) case "TEL": c.Tel = append(c.Tel, value) case "ORG": c.Org = value case "NOTE": c.Note = unescape(value) } } if c.UID == "" { return nil, fmt.Errorf("vcard missing required UID property") } return c, nil } // Build renders a Card back into vCard 4.0 text, CRLF line endings per spec. func (c *Card) Build() string { var b strings.Builder b.WriteString("BEGIN:VCARD\r\n") b.WriteString("VERSION:4.0\r\n") b.WriteString("UID:" + c.UID + "\r\n") if c.FN != "" { b.WriteString("FN:" + escape(c.FN) + "\r\n") } if c.N != "" { b.WriteString("N:" + c.N + "\r\n") } for _, e := range c.Email { b.WriteString("EMAIL:" + e + "\r\n") } for _, t := range c.Tel { b.WriteString("TEL:" + t + "\r\n") } if c.Org != "" { b.WriteString("ORG:" + escape(c.Org) + "\r\n") } if c.Note != "" { b.WriteString("NOTE:" + escape(c.Note) + "\r\n") } b.WriteString("END:VCARD\r\n") return b.String() } // splitProperty splits "NAME;PARAM=x:value" into (name, value) โ€” parameters // are discarded in this minimal pass (deferred: TYPE=work/home distinction). func splitProperty(line string) (name, value string, found bool) { colonIdx := strings.Index(line, ":") if colonIdx == -1 { return "", "", false } namePart := line[:colonIdx] value = line[colonIdx+1:] if semiIdx := strings.Index(namePart, ";"); semiIdx != -1 { namePart = namePart[:semiIdx] } return namePart, value, true } // unfold reverses RFC 6350 ยง3.2 line folding (a line starting with a single // space or tab is a continuation of the previous line). func unfold(data string) []string { raw := strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n") var out []string for _, line := range raw { if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 { out[len(out)-1] += line[1:] } else { out = append(out, line) } } return out } func escape(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, ",", "\\,") s = strings.ReplaceAll(s, ";", "\\;") s = strings.ReplaceAll(s, "\n", "\\n") return s } func unescape(s string) string { s = strings.ReplaceAll(s, "\\n", "\n") s = strings.ReplaceAll(s, "\\,", ",") s = strings.ReplaceAll(s, "\\;", ";") s = strings.ReplaceAll(s, "\\\\", "\\") return s }