// Package mailclient reads the mail client, app or webmail, and device behind // a tracking request, and never claims what a proxy's user agent cannot say. package mailclient import ( "strings" "github.com/mileusna/useragent" "github.com/warmbly/warmbly/internal/models" ) // ClientType values, as the open and click logs store them. const ( App = models.EngagementClientApp Webmail = models.EngagementClientWebmail ) // Locate is how far the request's network places the reader. type Locate int const ( LocateFull Locate = iota // the reader's own network LocateRegion // a relay that keeps the region, not the city (Apple MPP) LocateNone // a provider's data centre (Gmail, Yahoo) ) // Reading is what one request says about who made it; empty when unknown. type Reading struct { Client string // product name: "Gmail", "Apple Mail", "Outlook" ClientType string // App, Webmail or empty // DeviceHidden marks a fetch by a provider's image proxy. DeviceHidden bool DeviceType string // "desktop", "mobile", "tablet" or empty OS string Browser string BrowserVersion string Locate Locate } // Detect reads a user agent. click marks a followed link rather than a pixel: // its user agent is the browser the link opened in, so it never names webmail. func Detect(userAgent string, click bool) Reading { raw := strings.TrimSpace(userAgent) if raw == "" { return Reading{} } ua := strings.ToLower(raw) // Proxies first: their user agents carry a fixed, fake browser. A proxy // only loads images, so on a link the same string claims nothing. for _, p := range proxies { if p.match(ua) { if click { return Reading{} } return Reading{Client: p.client, DeviceHidden: true, Locate: p.locate} } } parsed := useragent.Parse(raw) r := Reading{ OS: parsed.OS, Browser: parsed.Name, BrowserVersion: parsed.Version, DeviceType: deviceType(parsed), } // A named client on a click made the request itself (an in-app browser, // Outlook's link check), so the click is still in that app. if a, ok := namedApp(ua); ok { r.Client, r.ClientType = a.client, App r.Browser, r.BrowserVersion = "", "" if r.OS == "" { r.OS = a.os } if r.DeviceType == "" { r.DeviceType = a.device } return r } if IsBareWebKit(ua) { // Mac Mail, iPad Mail or the new Outlook for Windows: an app, on a // device the string cannot name. The parser reads its platform as a // browser, so nothing else is kept. if click { return Reading{} } return Reading{Client: BareWebKitClient, ClientType: App} } if click { return r } switch { case isAppleMailWebView(ua, parsed): // iOS Mail renders in a WKWebView, which drops Safari's own tokens. r.Client, r.ClientType = "Apple Mail", App r.Browser, r.BrowserVersion = "", "" case isAndroidWebView(ua): // A mail app's embedded WebView; the app itself goes unnamed. r.ClientType = App r.Browser, r.BrowserVersion = "", "" case isBrowser(ua, parsed): r.ClientType = Webmail } return r } // proxy is a provider that fetches every image itself. type proxy struct { client string locate Locate match func(ua string) bool } var proxies = []proxy{ {client: "Gmail", locate: LocateNone, match: func(ua string) bool { return strings.Contains(ua, "googleimageproxy") || strings.Contains(ua, "via ggpht.com") }}, {client: "Yahoo Mail", locate: LocateNone, match: func(ua string) bool { return strings.Contains(ua, "yahoomailproxy") }}, // Mail Privacy Protection sends nothing but the product token. {client: "Apple Mail", locate: LocateRegion, match: func(ua string) bool { return ua == "mozilla/5.0" }}, {client: "HEY", locate: LocateNone, match: func(ua string) bool { return strings.Contains(ua, "hey.com/imageproxy") }}, {client: "Fastmail", locate: LocateNone, match: func(ua string) bool { return strings.Contains(ua, "fastmailua") }}, {client: "Seznam Email", locate: LocateNone, match: func(ua string) bool { return strings.Contains(ua, "seznamemailproxy") }}, } // BareWebKitClient names the stripped WebKit signature Mac Mail and the new // Outlook for Windows both send, from the reader's own network (issue #564). const BareWebKitClient = "Apple Mail or Outlook" // IsBareWebKit reports the stripped WebKit user agent with nothing after the // engine (Mail on a Mac, the new Outlook for Windows). func IsBareWebKit(ua string) bool { ua = strings.ToLower(strings.TrimSpace(ua)) return strings.Contains(ua, "applewebkit/") && strings.HasSuffix(ua, "(khtml, like gecko)") } // IsPrivacyProxy reports Mail Privacy Protection's bare product token. func IsPrivacyProxy(ua string) bool { return strings.ToLower(strings.TrimSpace(ua)) == "mozilla/5.0" } // app is a mail client that names itself in its user agent. type app struct { client string tokens []string // os and device are what the client implies when the string names no // platform (Outlook's Word renderer runs only on Windows). os, device string } // apps are matched in order, so a more specific token sits above a looser one. var apps = []app{ {client: "Outlook", tokens: []string{"outlook-ios-android", "outlook-android"}, os: "Android", device: "mobile"}, {client: "Outlook", tokens: []string{"outlook-ios"}, os: "iOS", device: "mobile"}, {client: "Outlook", tokens: []string{"macoutlook"}, os: "macOS", device: "desktop"}, {client: "Outlook", tokens: []string{"microsoft outlook", "ms-office", "msoffice"}, os: "Windows", device: "desktop"}, {client: "Outlook", tokens: []string{"outlook"}}, {client: "Thunderbird", tokens: []string{"thunderbird/"}, device: "desktop"}, {client: "eM Client", tokens: []string{"em client"}, device: "desktop"}, {client: "Mailbird", tokens: []string{"mailbird/"}, os: "Windows", device: "desktop"}, {client: "Mailspring", tokens: []string{"mailspring/"}, device: "desktop"}, {client: "BlueMail", tokens: []string{"bluemail/"}}, {client: "Superhuman", tokens: []string{"superhuman"}}, {client: "Proton Mail", tokens: []string{"protonmail", "proton mail"}}, {client: "Yahoo Mail", tokens: []string{"yahoo mail"}}, {client: "HEY", tokens: []string{"hey.com"}}, } func namedApp(ua string) (app, bool) { for _, a := range apps { for _, t := range a.tokens { if strings.Contains(ua, t) { return a, true } } } return app{}, false } // inAppTokens mark an iOS WebView that belongs to a browser or another app. var inAppTokens = []string{ "safari/", "crios", "fxios", "edgios", "opios", "gsa/", "fban", "fbav", "instagram", "line/", "micromessenger", "twitter", "linkedinapp", "snapchat", "pinterest", "duckduckgo", } // isAppleMailWebView is Mail on an iPhone or iPad: iOS WebKit with the build // token and nothing a browser or another app adds. func isAppleMailWebView(ua string, parsed useragent.UserAgent) bool { if parsed.OS != useragent.IOS || !strings.Contains(ua, "applewebkit/") || !strings.Contains(ua, "mobile/") { return false } for _, t := range inAppTokens { if strings.Contains(ua, t) { return false } } return true } // isAndroidWebView is an Android app's embedded WebView, which names no app. func isAndroidWebView(ua string) bool { return strings.Contains(ua, "android") && strings.Contains(ua, "; wv)") } // isBrowser is a full browser: webmail read in a tab. func isBrowser(ua string, parsed useragent.UserAgent) bool { if parsed.Bot { return false } switch parsed.Name { case useragent.Chrome, useragent.Firefox, useragent.Safari, useragent.Edge, useragent.Opera, useragent.Vivaldi, useragent.SamsungBrowser, useragent.MobileSafari: return strings.Contains(ua, "mozilla/5.0") } return false } // deviceType folds the parser's flags into desktop, mobile or tablet. func deviceType(ua useragent.UserAgent) string { switch { case ua.Tablet: return "tablet" case ua.Mobile: return "mobile" case ua.Desktop: return "desktop" } return "" }