Files

244 lines
7.8 KiB
Go

// 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 ""
}