mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 16:02:07 +00:00
270 lines
8.4 KiB
Go
270 lines
8.4 KiB
Go
package mailvendor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Mailforge (https://api.mailforge.ai/swagger/doc.json) and Infraforge
|
|
// (https://api.infraforge.ai/public/swagger/doc.json) share one API shape.
|
|
// The key reaches every workspace of the account, and the mailbox list spans them all.
|
|
type forge struct {
|
|
vendor string
|
|
t *transport
|
|
cache credCache
|
|
dnsMu sync.Mutex
|
|
workspaces workspaceCache
|
|
}
|
|
|
|
func newForge(vendor, base string, vals map[string]string, o options) *forge {
|
|
key := vals[FieldAPIKey]
|
|
// Both take the raw key, with no Bearer prefix.
|
|
auth := func(h http.Header) { h.Set("Authorization", key) }
|
|
return &forge{vendor: vendor, t: newTransport(vendor, base, o, auth, 0)}
|
|
}
|
|
|
|
func (c *forge) Vendor() string { return c.vendor }
|
|
|
|
// listWorkspaces reads GET /workspaces; a body in another shape yields no names rather than an error.
|
|
func (c *forge) listWorkspaces(ctx context.Context) ([]workspace, error) {
|
|
var raw json.RawMessage
|
|
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/workspaces"}, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
var res []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
_ = json.Unmarshal(raw, &res)
|
|
out := make([]workspace, 0, len(res))
|
|
for _, w := range res {
|
|
out = append(out, workspace{ID: w.ID, Name: w.Name})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *forge) Verify(ctx context.Context) error {
|
|
return c.t.do(ctx, call{method: http.MethodGet, path: "/workspaces"}, nil)
|
|
}
|
|
|
|
// workspaceNames labels mailboxes by workspace; it is cosmetic, so a failed read leaves them unlabelled.
|
|
func (c *forge) workspaceNames(ctx context.Context) map[string]string {
|
|
wss, err := c.workspaces.get(ctx, c.listWorkspaces)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
names := make(map[string]string, len(wss))
|
|
for _, w := range wss {
|
|
names[w.ID] = w.Name
|
|
}
|
|
return names
|
|
}
|
|
|
|
type forgeMailbox struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
FirstName string `json:"firstName"`
|
|
LastName string `json:"lastName"`
|
|
Domain string `json:"domain"`
|
|
Status string `json:"status"`
|
|
WorkspaceID string `json:"workspaceId"`
|
|
Credentials *struct {
|
|
IMAPHost string `json:"imapHost"`
|
|
IMAPPort int `json:"imapPort"`
|
|
IMAPUsername string `json:"imapUsername"`
|
|
IMAPPassword string `json:"imapPassword"`
|
|
SMTPHost string `json:"smtpHost"`
|
|
SMTPPort int `json:"smtpPort"`
|
|
SMTPUsername string `json:"smtpUsername"`
|
|
SMTPPassword string `json:"smtpPassword"`
|
|
} `json:"credentials"`
|
|
}
|
|
|
|
func (m forgeMailbox) credentials() (Credentials, bool) {
|
|
k := m.Credentials
|
|
if k == nil {
|
|
return Credentials{}, false
|
|
}
|
|
cr := Credentials{
|
|
Password: firstNonEmpty(k.SMTPPassword, k.IMAPPassword),
|
|
SMTP: endpoint(k.SMTPHost, k.SMTPPort, firstNonEmpty(k.SMTPUsername, m.Email)),
|
|
IMAP: endpoint(k.IMAPHost, k.IMAPPort, firstNonEmpty(k.IMAPUsername, m.Email)),
|
|
}
|
|
if cr.SMTP != nil && k.SMTPPassword != "" && k.SMTPPassword != cr.Password {
|
|
cr.SMTP.Password = k.SMTPPassword
|
|
}
|
|
if cr.IMAP != nil && k.IMAPPassword != "" && k.IMAPPassword != cr.Password {
|
|
cr.IMAP.Password = k.IMAPPassword
|
|
}
|
|
return cr, cr.Password != "" || cr.SMTP != nil || cr.IMAP != nil
|
|
}
|
|
|
|
func (m forgeMailbox) mailbox() Mailbox {
|
|
return Mailbox{
|
|
ID: m.ID,
|
|
Email: m.Email,
|
|
FirstName: m.FirstName,
|
|
LastName: m.LastName,
|
|
Domain: firstNonEmpty(m.Domain, domainOf(m.Email)),
|
|
// Both vendors host the mailboxes on their own SMTP/IMAP servers.
|
|
Provider: ProviderSMTP,
|
|
Status: m.Status,
|
|
}
|
|
}
|
|
|
|
// List reads every workspace's mailboxes with credentials in one unpaginated call.
|
|
func (c *forge) List(ctx context.Context) ([]Mailbox, error) {
|
|
q := url.Values{"with_credentials": {"true"}}
|
|
var res []forgeMailbox
|
|
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/mailboxes", query: q, limit: listBodyLimit}, &res); err != nil {
|
|
return nil, err
|
|
}
|
|
names := c.workspaceNames(ctx)
|
|
out := make([]Mailbox, 0, min(len(res), MaxMailboxes))
|
|
for _, m := range res {
|
|
if m.ID == "" {
|
|
continue
|
|
}
|
|
if cr, ok := m.credentials(); ok {
|
|
c.cache.put(m.ID, cr)
|
|
}
|
|
mb := m.mailbox()
|
|
mb.Workspace = names[m.WorkspaceID]
|
|
out = append(out, mb)
|
|
if len(out) >= MaxMailboxes {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Credentials comes from List's cache, else from the single-mailbox endpoint.
|
|
func (c *forge) Credentials(ctx context.Context, m Mailbox) (Credentials, error) {
|
|
if cr, ok := c.cache.get(m.ID); ok {
|
|
return cr, nil
|
|
}
|
|
if m.ID == "" {
|
|
return Credentials{}, vendorErr(c.vendor, 0, "mailbox id is required", ErrNotFound)
|
|
}
|
|
var res forgeMailbox
|
|
q := url.Values{"with_credentials": {"true"}}
|
|
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/mailboxes/" + url.PathEscape(strings.TrimSpace(m.ID)), query: q}, &res); err != nil {
|
|
return Credentials{}, err
|
|
}
|
|
cr, _ := res.credentials()
|
|
return cr, nil
|
|
}
|
|
|
|
func (c *forge) DomainCapabilities() DomainCapabilities {
|
|
// No endpoint removes a forward; A/AAAA stay out since the root's record name is undocumented.
|
|
return DomainCapabilities{Forwarding: true, DNS: true, DNSTypes: []string{DNSTypeCNAME, DNSTypeTXT}}
|
|
}
|
|
|
|
type forgeDomain struct {
|
|
ID string `json:"id"`
|
|
SLD string `json:"sld"`
|
|
TLD string `json:"tld"`
|
|
ForwardToDomain string `json:"forwardToDomain"`
|
|
}
|
|
|
|
// Domains reads GET /domains in one unpaginated call.
|
|
func (c *forge) Domains(ctx context.Context) ([]Domain, error) {
|
|
var raw json.RawMessage
|
|
if err := c.t.do(ctx, call{method: http.MethodGet, path: "/domains", limit: listBodyLimit}, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
var res []forgeDomain
|
|
// Mailforge documents an array; Infraforge's spec shows a single object, so both are read.
|
|
if err := json.Unmarshal(raw, &res); err != nil {
|
|
var one forgeDomain
|
|
if err := json.Unmarshal(raw, &one); err != nil {
|
|
return nil, vendorErr(c.vendor, http.StatusOK, "malformed response", nil)
|
|
}
|
|
if one.ID != "" {
|
|
res = []forgeDomain{one}
|
|
}
|
|
}
|
|
out := make([]Domain, 0, min(len(res), MaxDomains))
|
|
for _, d := range res {
|
|
if d.ID == "" {
|
|
continue
|
|
}
|
|
name := d.SLD
|
|
if d.TLD != "" {
|
|
name += "." + strings.TrimPrefix(d.TLD, ".")
|
|
}
|
|
out = append(out, Domain{ID: d.ID, Name: name, Forwarding: d.ForwardToDomain})
|
|
if len(out) >= MaxDomains {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SetForwarding calls PATCH /domains/forwards. domainMasking is omitted, which the docs ask for on a new forward.
|
|
func (c *forge) SetForwarding(ctx context.Context, d Domain, target string) error {
|
|
target, err := forwardingTarget(c.vendor, target, c.DomainCapabilities())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.ID == "" {
|
|
return invalid(c.vendor, "domain id is required")
|
|
}
|
|
body := []map[string]string{{"domainId": d.ID, "forwardToDomain": target}}
|
|
return c.t.do(ctx, call{method: http.MethodPatch, path: "/domains/forwards", body: body}, nil)
|
|
}
|
|
|
|
type forgeRecord struct {
|
|
Editable *bool `json:"editable,omitempty"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// UpsertDNSRecord reads GET /domains/{id}/dns and PUTs the whole set back (the PUT replaces every record),
|
|
// in listed order as Infraforge asks, new record appended, nothing dropped.
|
|
func (c *forge) UpsertDNSRecord(ctx context.Context, d Domain, r DNSRecord) error {
|
|
r, domain, err := prepareRecord(c.vendor, d, r, c.DomainCapabilities())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.ID == "" {
|
|
return invalid(c.vendor, "domain id is required")
|
|
}
|
|
// A read-modify-write of the whole set; serialize it so two upserts from this client cannot lose each other.
|
|
c.dnsMu.Lock()
|
|
defer c.dnsMu.Unlock()
|
|
path := "/domains/" + url.PathEscape(d.ID) + "/dns"
|
|
var records []forgeRecord
|
|
if err := c.t.do(ctx, call{method: http.MethodGet, path: path}, &records); err != nil {
|
|
return err
|
|
}
|
|
existing := make([]existingRecord, len(records))
|
|
for i, e := range records {
|
|
existing[i] = existingRecord{Type: e.Type, Name: absoluteHost(e.Name, domain), Value: e.Value, Locked: e.Editable != nil && !*e.Editable}
|
|
}
|
|
p := planUpsert(existing, r)
|
|
if p.noop {
|
|
return nil
|
|
}
|
|
p.remove = nil
|
|
if err := checkPlan(c.vendor, existing, p); err != nil {
|
|
return err
|
|
}
|
|
next := slices.Clone(records)
|
|
if next == nil {
|
|
next = []forgeRecord{}
|
|
}
|
|
if p.update >= 0 {
|
|
next[p.update].Value = r.Value
|
|
} else {
|
|
next = append(next, forgeRecord{Name: relativeHost(r.Name, domain), Type: r.Type, Value: r.Value})
|
|
}
|
|
return c.t.do(ctx, call{method: http.MethodPut, path: path, body: map[string]any{"records": next}}, nil)
|
|
}
|