mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 08:02:14 +00:00
495 lines
16 KiB
Go
495 lines
16 KiB
Go
package mailboximport
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/mailhost"
|
|
)
|
|
|
|
// payload is what a row carries into the queue, sealed with the workspace's key.
|
|
type payload struct {
|
|
Name string `json:"name"`
|
|
// NameGiven is true when the file named the sender, so an update may rename.
|
|
NameGiven bool `json:"name_given,omitempty"`
|
|
SMTP *models.Service `json:"smtp,omitempty"`
|
|
IMAP *models.Service `json:"imap,omitempty"`
|
|
MailHost string `json:"mail_host"`
|
|
AuthMethod string `json:"auth_method"`
|
|
// Signin marks a row that connects through Google or Microsoft sign-in.
|
|
Signin bool `json:"signin,omitempty"`
|
|
// GrantID connects the row through an administrator's grant over its domain.
|
|
GrantID *uuid.UUID `json:"grant_id,omitempty"`
|
|
// Vendor rows fetch their credentials from the vendor when they are worked.
|
|
VendorConnectionID *uuid.UUID `json:"vendor_connection_id,omitempty"`
|
|
VendorMailboxID string `json:"vendor_mailbox_id,omitempty"`
|
|
VendorProvider string `json:"vendor_provider,omitempty"`
|
|
Settings models.MailboxImportSettings `json:"settings"`
|
|
TagNames []string `json:"tag_names,omitempty"`
|
|
// TrackingDomain and RedirectURL are the domain-level choices made in the review step.
|
|
TrackingDomain string `json:"tracking_domain,omitempty"`
|
|
RedirectURL string `json:"redirect_url,omitempty"`
|
|
// email and importID are the row the payload belongs to, set when it is worked.
|
|
email string
|
|
importID uuid.UUID
|
|
}
|
|
|
|
// builtRow is one data row after mapping, detection and validation.
|
|
type builtRow struct {
|
|
line int
|
|
email string
|
|
domain string
|
|
status string // a models.ImportPreview* value
|
|
cause string
|
|
problem string
|
|
payload payload
|
|
fields map[string]string
|
|
}
|
|
|
|
// buildContext is what every row is judged against.
|
|
type buildContext struct {
|
|
headers []string
|
|
mapping models.MailboxImportMapping
|
|
detections map[string]mailhost.Detection
|
|
existing map[string]models.EmailRef
|
|
sharedPassword string
|
|
// googleSignin is true when this instance connects new Gmail mailboxes with Google sign-in.
|
|
googleSignin bool
|
|
// grants maps "provider\x00domain" to the administrator's grant covering it.
|
|
grants map[string]uuid.UUID
|
|
}
|
|
|
|
func grantKey(provider, domain string) string { return provider + "\x00" + domain }
|
|
|
|
// columnFor inverts the mapping: field -> column index.
|
|
func columnFor(m models.MailboxImportMapping) map[models.MailboxImportField]int {
|
|
out := map[models.MailboxImportField]int{}
|
|
for key, f := range m {
|
|
if f == models.ImportFieldIgnore {
|
|
continue
|
|
}
|
|
if i, err := strconv.Atoi(key); err == nil {
|
|
out[f] = i
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func domainOf(email string) string {
|
|
at := strings.LastIndex(email, "@")
|
|
if at < 0 {
|
|
return ""
|
|
}
|
|
return mailhost.NormalizeDomain(email[at+1:])
|
|
}
|
|
|
|
// emailsAndDomains reads the email column only, so detection and the
|
|
// existing-mailbox lookup can run before the rows are built.
|
|
func emailsAndDomains(body [][]string, m models.MailboxImportMapping) ([]string, []string) {
|
|
col, ok := columnFor(m)[models.ImportFieldEmail]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
var emails, domains []string
|
|
seen := map[string]bool{}
|
|
for _, rec := range body {
|
|
if col >= len(rec) {
|
|
continue
|
|
}
|
|
e := strings.TrimSpace(rec[col])
|
|
if !emailRe.MatchString(e) {
|
|
continue
|
|
}
|
|
emails = append(emails, e)
|
|
if d := domainOf(e); d != "" && !seen[d] {
|
|
seen[d] = true
|
|
domains = append(domains, d)
|
|
}
|
|
}
|
|
return emails, domains
|
|
}
|
|
|
|
// buildRows judges every data row. firstLine is the file line of body[0].
|
|
func buildRows(bc buildContext, body [][]string, firstLine int) []builtRow {
|
|
cols := columnFor(bc.mapping)
|
|
seen := map[string]bool{}
|
|
out := make([]builtRow, 0, len(body))
|
|
for i, rec := range body {
|
|
get := func(f models.MailboxImportField) string {
|
|
if c, ok := cols[f]; ok && c < len(rec) {
|
|
return strings.TrimSpace(rec[c])
|
|
}
|
|
return ""
|
|
}
|
|
r := buildRow(bc, get, firstLine+i)
|
|
r.fields = rowFields(bc, rec)
|
|
if r.email != "" && r.status != models.ImportPreviewInvalid {
|
|
key := strings.ToLower(r.email)
|
|
if seen[key] {
|
|
r.status, r.cause, r.problem = models.ImportPreviewDuplicate, causeDuplicateRow, "Listed earlier in the file."
|
|
} else {
|
|
seen[key] = true
|
|
if _, ok := bc.existing[key]; ok && r.status != models.ImportPreviewNeedsSignin {
|
|
r.status = models.ImportPreviewExisting
|
|
}
|
|
}
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rowFields keeps the uploaded cells that are safe to show again, by header.
|
|
func rowFields(bc buildContext, rec []string) map[string]string {
|
|
fields := make(map[string]string, len(bc.headers))
|
|
for i, h := range bc.headers {
|
|
if i >= len(rec) {
|
|
break
|
|
}
|
|
f := bc.mapping[strconv.Itoa(i)]
|
|
if secretFields[f] || (f == models.ImportFieldIgnore && looksSecret(rec[i])) {
|
|
continue
|
|
}
|
|
fields[h] = rec[i]
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func buildRow(bc buildContext, get func(models.MailboxImportField) string, line int) builtRow {
|
|
r := builtRow{line: line}
|
|
invalid := func(cause, problem string) builtRow {
|
|
r.status, r.cause, r.problem = models.ImportPreviewInvalid, cause, problem
|
|
return r
|
|
}
|
|
|
|
r.email = get(models.ImportFieldEmail)
|
|
if r.email == "" {
|
|
return invalid(causeMissingEmail, "No email address.")
|
|
}
|
|
if !emailRe.MatchString(r.email) {
|
|
return invalid(causeInvalidEmail, fmt.Sprintf("%q is not an email address.", r.email))
|
|
}
|
|
r.domain = domainOf(r.email)
|
|
|
|
name := get(models.ImportFieldName)
|
|
if name == "" {
|
|
name = strings.TrimSpace(get(models.ImportFieldFirstName) + " " + get(models.ImportFieldLastName))
|
|
}
|
|
r.payload.NameGiven = name != ""
|
|
if name == "" {
|
|
name = nameFromEmail(r.email)
|
|
}
|
|
r.payload.Name = name
|
|
|
|
settings, tags, problem := rowSettings(get)
|
|
r.payload.Settings, r.payload.TagNames = settings, tags
|
|
if problem != "" {
|
|
return invalid(causeInvalidValue, problem)
|
|
}
|
|
|
|
// Servers: the file's own, else what the domain's DNS says.
|
|
det := bc.detections[r.domain]
|
|
smtpHost, imapHost := get(models.ImportFieldSMTPHost), get(models.ImportFieldIMAPHost)
|
|
host := det.Host
|
|
var settingsFound *mailhost.Settings
|
|
if smtpHost != "" || imapHost != "" {
|
|
host = mailhost.Refine(mailhost.FromServer(firstNonEmpty(smtpHost, imapHost)), r.domain)
|
|
if host == mailhost.Unknown {
|
|
host = mailhost.Other
|
|
}
|
|
base := det.Settings
|
|
if base == nil {
|
|
base = mailhost.SettingsFor(host)
|
|
}
|
|
s := mailhost.Settings{}
|
|
if base != nil {
|
|
s = *base
|
|
}
|
|
if smtpHost != "" {
|
|
s.SMTP = mailhost.Endpoint{Host: smtpHost}
|
|
}
|
|
if imapHost != "" {
|
|
s.IMAP = mailhost.Endpoint{Host: imapHost}
|
|
}
|
|
settingsFound = &s
|
|
} else if det.Settings != nil {
|
|
s := *det.Settings
|
|
settingsFound = &s
|
|
}
|
|
r.payload.MailHost = string(host)
|
|
|
|
pa := mailhost.PasswordAuthFor(host)
|
|
if det.PasswordAuth != "" && smtpHost == "" && imapHost == "" {
|
|
pa = det.PasswordAuth
|
|
}
|
|
switch pa {
|
|
case mailhost.Unsupported:
|
|
if host == mailhost.Proton {
|
|
return invalid(causeProviderUnsupport, "Proton Mail does not offer IMAP sign-in to other apps.")
|
|
}
|
|
case mailhost.OAuthOnly:
|
|
if id, ok := bc.grants[grantKey(models.GrantProviderMicrosoft, r.domain)]; ok {
|
|
return delegated(r, id)
|
|
}
|
|
r.status, r.cause = models.ImportPreviewNeedsSignin, causeMicrosoftSignin
|
|
r.payload.Signin, r.payload.AuthMethod = true, models.MailAuthOAuth
|
|
return r
|
|
}
|
|
// A Workspace domain an administrator granted connects through the grant: no app password to expire.
|
|
if host.Google() {
|
|
if id, ok := bc.grants[grantKey(models.GrantProviderGoogle, r.domain)]; ok {
|
|
return delegated(r, id)
|
|
}
|
|
}
|
|
|
|
// Passwords, per leg: a leg's own column, then an app password where the
|
|
// host wants one, then the account password, then the shared one.
|
|
appPw := mailhost.NormalizeAppPassword(host, get(models.ImportFieldAppPassword))
|
|
accountPw := get(models.ImportFieldPassword)
|
|
pick := func(own string) string {
|
|
if own != "" {
|
|
return own
|
|
}
|
|
if appPw != "" && (pa == mailhost.AppPassword || accountPw == "") {
|
|
return appPw
|
|
}
|
|
if accountPw != "" {
|
|
return mailhost.NormalizeAppPassword(host, accountPw)
|
|
}
|
|
return mailhost.NormalizeAppPassword(host, bc.sharedPassword)
|
|
}
|
|
smtpPw, imapPw := pick(get(models.ImportFieldSMTPPassword)), pick(get(models.ImportFieldIMAPPassword))
|
|
username := get(models.ImportFieldUsername)
|
|
if username == "" {
|
|
username = r.email
|
|
}
|
|
// Whatever the file gave is kept even on a row that cannot go yet, so a
|
|
// fix only has to supply the missing piece.
|
|
r.payload.SMTP = &models.Service{Username: firstNonEmpty(get(models.ImportFieldSMTPUsername), username), Password: smtpPw}
|
|
r.payload.IMAP = &models.Service{Username: firstNonEmpty(get(models.ImportFieldIMAPUsername), username), Password: imapPw}
|
|
r.payload.AuthMethod = mailhost.AuthMethodFor(host, pa)
|
|
|
|
if settingsFound == nil || settingsFound.SMTP.Host == "" || settingsFound.IMAP.Host == "" {
|
|
return invalid(causeUnknownServers, "No IMAP and SMTP servers found for "+r.domain+".")
|
|
}
|
|
smtp, err := endpoint(settingsFound.SMTP, get(models.ImportFieldSMTPPort), get(models.ImportFieldSMTPSecurity), "smtp")
|
|
if err != "" {
|
|
return invalid(causeInvalidValue, err)
|
|
}
|
|
imap, err := endpoint(settingsFound.IMAP, get(models.ImportFieldIMAPPort), get(models.ImportFieldIMAPSecurity), "imap")
|
|
if err != "" {
|
|
return invalid(causeInvalidValue, err)
|
|
}
|
|
r.payload.SMTP.Host, r.payload.SMTP.Port, r.payload.SMTP.Security = smtp.Host, smtp.Port, smtp.Security
|
|
r.payload.IMAP.Host, r.payload.IMAP.Port, r.payload.IMAP.Security = imap.Host, imap.Port, imap.Security
|
|
|
|
if host.Google() && !mailhost.LooksLikeGoogleAppPassword(smtpPw) {
|
|
if bc.googleSignin {
|
|
// A sign-in row keeps no password: Google would never accept it.
|
|
r.status, r.cause = models.ImportPreviewNeedsSignin, causeGoogleSignin
|
|
r.payload.Signin, r.payload.AuthMethod = true, models.MailAuthOAuth
|
|
r.payload.SMTP, r.payload.IMAP = nil, nil
|
|
return r
|
|
}
|
|
if smtpPw != "" {
|
|
return invalid("google_app_password_required", "Google needs a 16-letter app password here, not the account password.")
|
|
}
|
|
}
|
|
if smtpPw == "" || imapPw == "" {
|
|
return invalid(causeMissingPassword, "No password.")
|
|
}
|
|
r.status = models.ImportPreviewReady
|
|
return r
|
|
}
|
|
|
|
// delegated marks a row that connects through an administrator's grant.
|
|
func delegated(r builtRow, grantID uuid.UUID) builtRow {
|
|
id := grantID
|
|
r.payload.GrantID, r.payload.AuthMethod = &id, models.MailAuthDelegated
|
|
r.payload.SMTP, r.payload.IMAP = nil, nil
|
|
r.status = models.ImportPreviewReady
|
|
return r
|
|
}
|
|
|
|
// endpoint fills a leg's port and security from the file, defaulting from the detected settings.
|
|
func endpoint(base mailhost.Endpoint, portRaw, securityRaw, leg string) (mailhost.Endpoint, string) {
|
|
e := base
|
|
if portRaw != "" {
|
|
p, err := strconv.Atoi(portRaw)
|
|
if err != nil || p < 1 || p > 65535 {
|
|
return e, fmt.Sprintf("%s port %q is not a port number.", strings.ToUpper(leg), portRaw)
|
|
}
|
|
e.Port = p
|
|
if securityRaw == "" && p != base.Port {
|
|
e.Security = ""
|
|
}
|
|
}
|
|
if e.Port == 0 {
|
|
e.Port = map[string]int{"smtp": 587, "imap": 993}[leg]
|
|
}
|
|
if securityRaw != "" {
|
|
s, ok := parseSecurity(securityRaw, e.Port)
|
|
if !ok {
|
|
return e, fmt.Sprintf("%s security %q should be SSL/TLS or STARTTLS.", strings.ToUpper(leg), securityRaw)
|
|
}
|
|
e.Security = s
|
|
}
|
|
if e.Security == "" {
|
|
if leg == "smtp" {
|
|
e.Security = models.ResolveSMTPSecurity("", e.Port)
|
|
} else {
|
|
e.Security = models.ResolveIMAPSecurity("", e.Port)
|
|
}
|
|
}
|
|
return e, ""
|
|
}
|
|
|
|
// parseSecurity reads the ways files spell encryption, including Smartlead's
|
|
// SSL/TLS port type and Instantly's true/false SSL column.
|
|
func parseSecurity(raw string, port int) (string, bool) {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "tls", "ssl", "ssl/tls", "implicit", "implicit tls":
|
|
return models.MailSecurityTLS, true
|
|
case "starttls", "start_tls", "start-tls", "start tls":
|
|
return models.MailSecurityStartTLS, true
|
|
case "true", "yes", "1", "on":
|
|
if port == 587 || port == 143 || port == 25 {
|
|
return models.MailSecurityStartTLS, true
|
|
}
|
|
return models.MailSecurityTLS, true
|
|
case "false", "no", "0", "off":
|
|
// "No SSL" on a submission port is STARTTLS in practice; plaintext is never offered.
|
|
return models.MailSecurityStartTLS, true
|
|
case "none", "plain":
|
|
return models.MailSecurityNone, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// rowSettings reads the per-row settings columns. A value that does not parse
|
|
// is a problem with the row rather than something to guess at.
|
|
func rowSettings(get func(models.MailboxImportField) string) (models.MailboxImportSettings, []string, string) {
|
|
var s models.MailboxImportSettings
|
|
intField := func(f models.MailboxImportField, label string, dst **int) string {
|
|
raw := get(f)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(raw), "%"))
|
|
if err != nil || n < 0 {
|
|
return fmt.Sprintf("%s %q is not a whole number.", label, raw)
|
|
}
|
|
*dst = &n
|
|
return ""
|
|
}
|
|
for _, p := range []string{
|
|
intField(models.ImportFieldDailyLimit, "Daily limit", &s.DailyLimit),
|
|
intField(models.ImportFieldMinWait, "Minimum wait", &s.MinWait),
|
|
intField(models.ImportFieldWarmupStart, "Warmup start", &s.WarmupStart),
|
|
intField(models.ImportFieldWarmupMax, "Warmup maximum", &s.WarmupMax),
|
|
intField(models.ImportFieldWarmupIncrease, "Warmup increase", &s.WarmupIncrease),
|
|
intField(models.ImportFieldWarmupReplyRate, "Warmup reply rate", &s.WarmupReplyRate),
|
|
} {
|
|
if p != "" {
|
|
return s, nil, p
|
|
}
|
|
}
|
|
if raw := get(models.ImportFieldWarmup); raw != "" {
|
|
switch strings.ToLower(raw) {
|
|
case "true", "yes", "y", "1", "on", "enabled":
|
|
t := true
|
|
s.Warmup = &t
|
|
case "false", "no", "n", "0", "off", "disabled":
|
|
f := false
|
|
s.Warmup = &f
|
|
default:
|
|
return s, nil, fmt.Sprintf("Warmup %q should be yes or no.", raw)
|
|
}
|
|
}
|
|
if v := get(models.ImportFieldReplyTo); v != "" {
|
|
if !emailRe.MatchString(v) {
|
|
return s, nil, fmt.Sprintf("Reply-to %q is not an email address.", v)
|
|
}
|
|
s.ReplyTo = &v
|
|
}
|
|
if v := get(models.ImportFieldSignature); v != "" {
|
|
s.Signature = &v
|
|
}
|
|
if v := get(models.ImportFieldTimezone); v != "" {
|
|
s.Timezone = &v
|
|
}
|
|
var tags []string
|
|
if v := get(models.ImportFieldTags); v != "" {
|
|
for _, t := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '|' }) {
|
|
if t = strings.TrimSpace(t); t != "" && len(tags) < 20 {
|
|
tags = append(tags, t)
|
|
}
|
|
}
|
|
}
|
|
return s, tags, ""
|
|
}
|
|
|
|
// mergeSettings lays a row's own settings over the import's batch settings.
|
|
func mergeSettings(batch, row models.MailboxImportSettings) models.MailboxImportSettings {
|
|
out := batch
|
|
if row.DailyLimit != nil {
|
|
out.DailyLimit = row.DailyLimit
|
|
}
|
|
if row.MinWait != nil {
|
|
out.MinWait = row.MinWait
|
|
}
|
|
if row.Warmup != nil {
|
|
out.Warmup = row.Warmup
|
|
}
|
|
if row.WarmupStart != nil {
|
|
out.WarmupStart = row.WarmupStart
|
|
}
|
|
if row.WarmupMax != nil {
|
|
out.WarmupMax = row.WarmupMax
|
|
}
|
|
if row.WarmupIncrease != nil {
|
|
out.WarmupIncrease = row.WarmupIncrease
|
|
}
|
|
if row.WarmupReplyRate != nil {
|
|
out.WarmupReplyRate = row.WarmupReplyRate
|
|
}
|
|
if row.ReplyTo != nil {
|
|
out.ReplyTo = row.ReplyTo
|
|
}
|
|
if row.Signature != nil {
|
|
out.Signature = row.Signature
|
|
}
|
|
if row.Timezone != nil {
|
|
out.Timezone = row.Timezone
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// nameFromEmail turns "alex.rivera@" into "Alex Rivera".
|
|
func nameFromEmail(email string) string {
|
|
local := email
|
|
if at := strings.Index(email, "@"); at >= 0 {
|
|
local = email[:at]
|
|
}
|
|
parts := strings.FieldsFunc(local, func(r rune) bool { return r == '.' || r == '_' || r == '-' || r == '+' })
|
|
for i, p := range parts {
|
|
if p != "" {
|
|
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|