mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 00:02:12 +00:00
577 lines
21 KiB
Go
577 lines
21 KiB
Go
// Package cloudlink is the self-hosted side of the warmup pool link.
|
|
package cloudlink
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/warmbly/warmbly/internal/app/email"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
const DefaultCloudURL = "https://api.warmbly.com"
|
|
|
|
var (
|
|
ErrNotConnected = errx.NewWithIdentifier(errx.Conflict, "cloud_link_not_connected", "This instance is not connected to Warmbly Cloud.")
|
|
ErrAlreadyLinked = errx.NewWithIdentifier(errx.Conflict, "cloud_link_connected", "This instance is already connected. Disconnect first to link a different workspace.")
|
|
ErrNoPendingCode = errx.NewWithIdentifier(errx.NotFound, "cloud_link_no_pending", "No connection in progress. Start again.")
|
|
ErrCodeExpired = errx.NewWithIdentifier(errx.NotFound, "cloud_link_code_expired", "The code expired before it was approved. Start again.")
|
|
ErrOAuthMailbox = errx.NewWithIdentifier(errx.Unprocessable, "cloud_link_oauth_mailbox", "Google and Microsoft sign-in mailboxes cannot be warmed by Warmbly Cloud yet, because their refresh grant is bound to this instance's own OAuth app. Connect the mailbox with SMTP/IMAP (an app password) to enroll it.")
|
|
ErrMailboxInactive = errx.NewWithIdentifier(errx.Unprocessable, "cloud_link_mailbox_inactive", "Only active mailboxes can be enrolled.")
|
|
ErrNoCredentialKey = errx.NewWithIdentifier(errx.Conflict, "cloud_link_no_key", "This instance has no CREDENTIALS_ENCRYPTION_KEY, so it cannot store the Warmbly Cloud token. Set one and restart before connecting.")
|
|
)
|
|
|
|
// cloudURLAllowed requires TLS: the token and mailbox passwords travel on
|
|
// this URL. Loopback is exempt for local development.
|
|
func cloudURLAllowed(u string) bool {
|
|
if strings.HasPrefix(u, "https://") {
|
|
return true
|
|
}
|
|
if !strings.HasPrefix(u, "http://") {
|
|
return false
|
|
}
|
|
host := strings.TrimPrefix(u, "http://")
|
|
if i := strings.IndexAny(host, ":/"); i >= 0 {
|
|
host = host[:i]
|
|
}
|
|
return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]"
|
|
}
|
|
|
|
// CloudURL is where the instance links to: WARMBLY_CLOUD_URL or the hosted API.
|
|
func CloudURL() string {
|
|
if v := strings.TrimRight(strings.TrimSpace(os.Getenv("WARMBLY_CLOUD_URL")), "/"); v != "" {
|
|
return v
|
|
}
|
|
return DefaultCloudURL
|
|
}
|
|
|
|
func instanceVersion() string {
|
|
if v := strings.TrimSpace(os.Getenv("WARMBLY_VERSION")); v != "" {
|
|
return v
|
|
}
|
|
return "dev"
|
|
}
|
|
|
|
func instanceName() string {
|
|
if v := strings.TrimSpace(os.Getenv("INSTANCE_NAME")); v != "" {
|
|
return v
|
|
}
|
|
if host, err := os.Hostname(); err == nil && host != "" {
|
|
return host
|
|
}
|
|
return "Self-hosted Warmbly"
|
|
}
|
|
|
|
// PendingConnect is an in-flight device-code handshake, held in memory.
|
|
type PendingConnect struct {
|
|
DeviceCode string `json:"-"`
|
|
UserCode string `json:"user_code"`
|
|
VerificationURL string `json:"verification_url"`
|
|
CloudURL string `json:"cloud_url"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
Interval int `json:"interval"`
|
|
StartedBy uuid.UUID `json:"-"`
|
|
}
|
|
|
|
// ConnectPollResult is the dashboard's poll answer.
|
|
type ConnectPollResult struct {
|
|
Status models.PoolLinkCodeStatus `json:"status"`
|
|
Link *models.CloudLink `json:"link,omitempty"`
|
|
Info *models.PoolLinkInstanceInfo `json:"info,omitempty"`
|
|
}
|
|
|
|
type Service interface {
|
|
Status(ctx context.Context) (*models.CloudLinkStatus, *errx.Error)
|
|
StartConnect(ctx context.Context, userID uuid.UUID, cloudURL string) (*PendingConnect, *errx.Error)
|
|
PollConnect(ctx context.Context, userID uuid.UUID) (*ConnectPollResult, *errx.Error)
|
|
Disconnect(ctx context.Context) *errx.Error
|
|
|
|
ListMailboxes(ctx context.Context, orgID uuid.UUID) ([]models.CloudLinkMailboxRow, *errx.Error)
|
|
Enroll(ctx context.Context, orgID, accountID uuid.UUID) (*models.CloudLinkMailboxRow, *errx.Error)
|
|
Unenroll(ctx context.Context, orgID, accountID uuid.UUID) *errx.Error
|
|
// RevokeForDelete releases the mailbox on the cloud, credential and link
|
|
// alike, without ever calling back into the email service.
|
|
RevokeForDelete(ctx context.Context, orgID, accountID uuid.UUID) *errx.Error
|
|
SetLifecycle(ctx context.Context, orgID, accountID uuid.UUID, action string) (*models.CloudLinkMailboxRow, *errx.Error)
|
|
|
|
// Cloud-managed mailboxes: consent through the cloud, tokens brokered from it (managed.go).
|
|
StartOAuth(ctx context.Context, orgID, userID uuid.UUID, provider models.InboxProvider) (*models.CloudLinkOAuthStart, *errx.Error)
|
|
FinishOAuth(ctx context.Context, orgID, userID uuid.UUID, session string) (*models.Email, *errx.Error)
|
|
ListWorkspaceMailboxes(ctx context.Context) ([]models.PoolLinkWorkspaceMailbox, *errx.Error)
|
|
Adopt(ctx context.Context, orgID, userID, cloudAccountID uuid.UUID) (*models.Email, *errx.Error)
|
|
// AccessToken is the worker's credential for a managed mailbox, via the internal API.
|
|
AccessToken(ctx context.Context, accountID uuid.UUID) (*models.PoolLinkAccessToken, *errx.Error)
|
|
|
|
// IsEnrolled is the local warmup scheduler's stand-down check; fails closed to false.
|
|
IsEnrolled(ctx context.Context, accountID uuid.UUID) bool
|
|
CheckEnrollment(ctx context.Context, accountID uuid.UUID) (bool, error)
|
|
// VerifyWarmupToken is the consumer's check that warmup mail in an enrolled mailbox is the cloud's.
|
|
VerifyWarmupToken(ctx context.Context, accountID uuid.UUID, token string) (bool, error)
|
|
// IsCloudWarmupDelivery is the same check for warmup mail whose verify header did not survive.
|
|
IsCloudWarmupDelivery(ctx context.Context, accountID uuid.UUID, sender, messageID, subject string) (bool, error)
|
|
// IsCloudWarmupThreadReply asks by ancestry: whether what a tokenless
|
|
// message answers is a turn of one of the cloud's warmup conversations.
|
|
IsCloudWarmupThreadReply(ctx context.Context, accountID uuid.UUID, messageID string, inReplyTo []string) (bool, error)
|
|
}
|
|
|
|
type service struct {
|
|
repo repository.CloudLinkRepository
|
|
emails repository.EmailRepository
|
|
emailSvc email.EmailService
|
|
|
|
mu sync.Mutex
|
|
pending *PendingConnect
|
|
sessions map[string]oauthSession
|
|
tokens map[uuid.UUID]cachedToken
|
|
}
|
|
|
|
func NewService(repo repository.CloudLinkRepository, emails repository.EmailRepository, emailSvc email.EmailService) Service {
|
|
return &service{repo: repo, emails: emails, emailSvc: emailSvc, sessions: map[string]oauthSession{}, tokens: map[uuid.UUID]cachedToken{}}
|
|
}
|
|
|
|
func (s *service) link(ctx context.Context) (*models.CloudLink, *errx.Error) {
|
|
l, err := s.repo.Get(ctx)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
if l == nil {
|
|
return nil, ErrNotConnected
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
func (s *service) clientFor(l *models.CloudLink) *client {
|
|
return newClient(l.CloudURL, l.Token, instanceVersion())
|
|
}
|
|
|
|
func (s *service) Status(ctx context.Context) (*models.CloudLinkStatus, *errx.Error) {
|
|
st := &models.CloudLinkStatus{DefaultCloudURL: CloudURL()}
|
|
l, err := s.repo.Get(ctx)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
if l == nil {
|
|
return st, nil
|
|
}
|
|
st.Connected = true
|
|
st.Link = l
|
|
var info models.PoolLinkInstanceInfo
|
|
if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance", nil, &info); xerr != nil {
|
|
st.Error = xerr.Message
|
|
_ = s.repo.SetSyncResult(ctx, time.Now(), xerr.Message)
|
|
return st, nil
|
|
}
|
|
st.Reachable = true
|
|
st.Info = &info
|
|
_ = s.repo.SetSyncResult(ctx, time.Now(), "")
|
|
return st, nil
|
|
}
|
|
|
|
func (s *service) StartConnect(ctx context.Context, userID uuid.UUID, cloudURL string) (*PendingConnect, *errx.Error) {
|
|
if l, err := s.repo.Get(ctx); err == nil && l != nil {
|
|
return nil, ErrAlreadyLinked
|
|
}
|
|
// The handshake is one-time: refuse it now rather than lose the token
|
|
// after a member has already approved the code on the cloud.
|
|
if err := s.repo.CanStore(); err != nil {
|
|
return nil, ErrNoCredentialKey
|
|
}
|
|
cloudURL = strings.TrimRight(strings.TrimSpace(cloudURL), "/")
|
|
if cloudURL == "" {
|
|
cloudURL = CloudURL()
|
|
}
|
|
if !cloudURLAllowed(cloudURL) {
|
|
return nil, errx.NewWithIdentifier(errx.BadRequest, "cloud_link_url", "The cloud URL must start with https://.")
|
|
}
|
|
c := newClient(cloudURL, "", instanceVersion())
|
|
var res models.PoolLinkStartResponse
|
|
if xerr := c.do(ctx, http.MethodPost, "/codes", models.PoolLinkStartRequest{
|
|
InstanceName: instanceName(),
|
|
InstanceURL: config.AppBaseURL(),
|
|
InstanceVersion: instanceVersion(),
|
|
}, &res); xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
p := &PendingConnect{
|
|
DeviceCode: res.DeviceCode,
|
|
UserCode: res.UserCode,
|
|
VerificationURL: res.VerificationURL,
|
|
CloudURL: cloudURL,
|
|
ExpiresAt: time.Now().Add(time.Duration(res.ExpiresIn) * time.Second),
|
|
Interval: res.Interval,
|
|
StartedBy: userID,
|
|
}
|
|
s.mu.Lock()
|
|
s.pending = p
|
|
s.mu.Unlock()
|
|
return p, nil
|
|
}
|
|
|
|
func (s *service) PollConnect(ctx context.Context, userID uuid.UUID) (*ConnectPollResult, *errx.Error) {
|
|
s.mu.Lock()
|
|
p := s.pending
|
|
s.mu.Unlock()
|
|
if p == nil {
|
|
// Another tab may have finished the handshake already.
|
|
if l, err := s.repo.Get(ctx); err == nil && l != nil {
|
|
return &ConnectPollResult{Status: models.PoolLinkCodeApproved, Link: l}, nil
|
|
}
|
|
return nil, ErrNoPendingCode
|
|
}
|
|
if time.Now().After(p.ExpiresAt) {
|
|
s.clearPending(p)
|
|
return nil, ErrCodeExpired
|
|
}
|
|
c := newClient(p.CloudURL, "", instanceVersion())
|
|
var res models.PoolLinkPollResponse
|
|
if xerr := c.do(ctx, http.MethodPost, "/poll", map[string]string{"device_code": p.DeviceCode}, &res); xerr != nil {
|
|
if xerr.Identifier == "pool_link_denied" || xerr.Identifier == "pool_link_code_not_found" || xerr.Identifier == "pool_link_code_used" {
|
|
s.clearPending(p)
|
|
}
|
|
return nil, xerr
|
|
}
|
|
if res.Status != models.PoolLinkCodeApproved {
|
|
return &ConnectPollResult{Status: models.PoolLinkCodePending}, nil
|
|
}
|
|
if res.InstanceID == nil || res.InstanceToken == "" {
|
|
return nil, errx.InternalError()
|
|
}
|
|
l := &models.CloudLink{
|
|
CloudURL: p.CloudURL,
|
|
InstanceID: *res.InstanceID,
|
|
Token: res.InstanceToken,
|
|
ConnectedBy: &userID,
|
|
}
|
|
if res.Organization != nil {
|
|
l.OrganizationName = res.Organization.Name
|
|
}
|
|
if err := s.repo.Put(ctx, l); err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
s.clearPending(p)
|
|
var info models.PoolLinkInstanceInfo
|
|
out := &ConnectPollResult{Status: models.PoolLinkCodeApproved, Link: l}
|
|
if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance", nil, &info); xerr == nil {
|
|
out.Info = &info
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *service) clearPending(p *PendingConnect) {
|
|
s.mu.Lock()
|
|
if s.pending == p {
|
|
s.pending = nil
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// linkAlreadyGone: the cloud no longer recognises the instance, so nothing is left to release there.
|
|
func linkAlreadyGone(xerr *errx.Error) bool {
|
|
switch xerr.Identifier {
|
|
case "pool_link_revoked", "pool_link_instance_not_found", "unauthorized":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *service) Disconnect(ctx context.Context) *errx.Error {
|
|
l, xerr := s.link(ctx)
|
|
if xerr != nil {
|
|
return xerr
|
|
}
|
|
// The cloud must confirm (or already have dropped) the link before local
|
|
// state goes, or managed mailboxes stay owned there with no way to retry.
|
|
if xerr := s.clientFor(l).do(ctx, http.MethodDelete, "/instance", nil, nil); xerr != nil && !linkAlreadyGone(xerr) {
|
|
return xerr
|
|
}
|
|
// Managed mirrors have no credential of their own; they end with the link.
|
|
// Enumerating them has to succeed before the link row goes: afterwards
|
|
// there is nothing left to retry from, and the mailboxes would be left
|
|
// warming nowhere.
|
|
rows, err := s.repo.List(ctx)
|
|
if err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
var released []uuid.UUID
|
|
for _, m := range rows {
|
|
if !m.Managed {
|
|
released = append(released, m.EmailAccountID)
|
|
continue
|
|
}
|
|
if s.emailSvc == nil {
|
|
continue
|
|
}
|
|
if acc, xerr := s.emails.GetByID(ctx, m.EmailAccountID); xerr == nil && acc.OrganizationID != nil {
|
|
s.forgetToken(m.EmailAccountID)
|
|
_ = s.emailSvc.Delete(ctx, acc.OrganizationID.String(), acc.ID.String())
|
|
}
|
|
}
|
|
if err := s.repo.UnenrollAll(ctx); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
if err := s.repo.Delete(ctx); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
// The mailboxes the cloud was warming rejoin this instance's pool.
|
|
for _, id := range released {
|
|
s.syncLocalPool(ctx, id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *service) IsEnrolled(ctx context.Context, accountID uuid.UUID) bool {
|
|
ok, err := s.repo.IsEnrolled(ctx, accountID)
|
|
return err == nil && ok
|
|
}
|
|
|
|
func (s *service) CheckEnrollment(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
|
return s.repo.IsEnrolled(ctx, accountID)
|
|
}
|
|
|
|
func (s *service) ListMailboxes(ctx context.Context, orgID uuid.UUID) ([]models.CloudLinkMailboxRow, *errx.Error) {
|
|
accounts, xerr := s.emails.GetAllActiveInScope(ctx, repository.NewAccountScope(&orgID))
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
enrolled, err := s.repo.List(ctx)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
byAccount := make(map[uuid.UUID]models.CloudLinkMailbox, len(enrolled))
|
|
for _, e := range enrolled {
|
|
byAccount[e.EmailAccountID] = e
|
|
}
|
|
|
|
// One round trip for every enrolled mailbox's cloud state.
|
|
cloudByRemote := map[uuid.UUID]*models.PoolLinkMailboxState{}
|
|
if len(enrolled) > 0 {
|
|
if l, err := s.repo.Get(ctx); err == nil && l != nil {
|
|
var states []models.PoolLinkMailboxState
|
|
if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance/mailboxes", nil, &states); xerr == nil {
|
|
for i := range states {
|
|
cloudByRemote[states[i].RemoteID] = &states[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
rows := make([]models.CloudLinkMailboxRow, 0, len(accounts))
|
|
seen := make(map[uuid.UUID]bool, len(accounts))
|
|
add := func(a *models.Email) {
|
|
seen[a.ID] = true
|
|
row := models.CloudLinkMailboxRow{ID: a.ID, Email: a.Email, Name: a.Name, Provider: a.Provider, Status: a.Status}
|
|
if e, ok := byAccount[a.ID]; ok {
|
|
at := e.EnrolledAt
|
|
row.Enrolled = true
|
|
row.EnrolledAt = &at
|
|
row.Managed = e.Managed
|
|
row.Cloud = cloudByRemote[e.RemoteID]
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
for i := range accounts {
|
|
add(&accounts[i])
|
|
}
|
|
// An enrolled mailbox the cloud still warms stays listed after it goes
|
|
// inactive; the scope query above returns active ones only, and dropping it
|
|
// would leave no way to see, pause or unenroll it.
|
|
for _, e := range enrolled {
|
|
if seen[e.EmailAccountID] {
|
|
continue
|
|
}
|
|
acc, xerr := s.emails.GetByID(ctx, e.EmailAccountID)
|
|
if xerr != nil || acc.OrganizationID == nil || *acc.OrganizationID != orgID {
|
|
continue
|
|
}
|
|
add(acc)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *service) row(ctx context.Context, orgID, accountID uuid.UUID) (*models.CloudLinkMailboxRow, *errx.Error) {
|
|
rows, xerr := s.ListMailboxes(ctx, orgID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
for i := range rows {
|
|
if rows[i].ID == accountID {
|
|
return &rows[i], nil
|
|
}
|
|
}
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
|
|
// ownedAccount refuses a mailbox from another workspace before anything acts on it.
|
|
func (s *service) ownedAccount(ctx context.Context, orgID, accountID uuid.UUID) (*models.Email, *errx.Error) {
|
|
acc, xerr := s.emails.GetByID(ctx, accountID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
if acc.OrganizationID == nil || *acc.OrganizationID != orgID {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
return acc, nil
|
|
}
|
|
|
|
// syncLocalPool re-reads the mailbox's local warmup pool membership. An
|
|
// enrolled mailbox must leave the local pool: warmup sent to it from here
|
|
// carries a token the cloud cannot vouch for, so it lands in the user's inbox.
|
|
func (s *service) syncLocalPool(ctx context.Context, accountID uuid.UUID) {
|
|
if s.emailSvc != nil {
|
|
s.emailSvc.SyncWarmupPool(ctx, accountID)
|
|
}
|
|
}
|
|
|
|
func (s *service) Enroll(ctx context.Context, orgID, accountID uuid.UUID) (*models.CloudLinkMailboxRow, *errx.Error) {
|
|
l, xerr := s.link(ctx)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
acc, xerr := s.ownedAccount(ctx, orgID, accountID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
if acc.Status != "active" {
|
|
return nil, ErrMailboxInactive
|
|
}
|
|
req := models.PoolLinkEnrollRequest{
|
|
RemoteID: acc.ID,
|
|
Email: acc.Email,
|
|
Name: acc.Name,
|
|
Provider: models.InboxProvider(acc.Provider),
|
|
Warmup: models.PoolLinkWarmupSettings{
|
|
Base: acc.WarmupBase, Max: acc.WarmupMax, Increase: acc.WarmupIncrease, ReplyRate: acc.WarmupReplyRate,
|
|
StartTime: acc.WarmupStartTime, EndTime: acc.WarmupEndTime, Days: acc.WarmupDays, Timezone: acc.Timezone,
|
|
},
|
|
}
|
|
switch req.Provider {
|
|
case models.InboxProviderSMTPIMAP:
|
|
creds, xerr := s.emails.GetSMTPCredentials(ctx, acc.ID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
req.SMTPIMAP = &models.SmtpImap{
|
|
SMTP: &models.Service{Host: creds.SMTPHost, Port: creds.SMTPPort, Username: creds.SMTPUser, Password: creds.SMTPPassword, Security: creds.SMTPSecurity},
|
|
IMAP: &models.Service{Host: creds.IMAPHost, Port: creds.IMAPPort, Username: creds.IMAPUser, Password: creds.IMAPPassword, Security: creds.IMAPSecurity},
|
|
}
|
|
default:
|
|
return nil, ErrOAuthMailbox
|
|
}
|
|
|
|
var state models.PoolLinkMailboxState
|
|
if xerr := s.clientFor(l).do(ctx, http.MethodPost, "/instance/mailboxes", req, &state); xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
if _, err := s.repo.Enroll(ctx, acc.ID, acc.ID, false); err != nil {
|
|
// Without the local row the mailbox would warm in both places; undo the cloud side.
|
|
if xerr := s.clientFor(l).do(ctx, http.MethodDelete, "/instance/mailboxes/"+acc.ID.String(), nil, nil); xerr != nil {
|
|
log.Error().Str("account_id", acc.ID.String()).Str("code", xerr.Identifier).Msg("cloud link: local enrollment failed and the cloud copy could not be removed; unenroll it from Settings")
|
|
}
|
|
return nil, errx.InternalError()
|
|
}
|
|
s.syncLocalPool(ctx, acc.ID)
|
|
return s.row(ctx, orgID, accountID)
|
|
}
|
|
|
|
func (s *service) Unenroll(ctx context.Context, orgID, accountID uuid.UUID) *errx.Error {
|
|
// Called for the tenant check, not the row: everything below keys on the
|
|
// workspace the caller proved here.
|
|
if _, xerr := s.ownedAccount(ctx, orgID, accountID); xerr != nil {
|
|
return xerr
|
|
}
|
|
m, err := s.repo.GetByAccount(ctx, accountID)
|
|
if err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
if m.Managed {
|
|
return s.removeManaged(ctx, orgID.String(), m)
|
|
}
|
|
// Local row first, so a failed cloud call can be retried from a consistent
|
|
// state instead of leaving the mailbox with no warmup anywhere.
|
|
if err := s.repo.Unenroll(ctx, accountID); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
if l, err := s.repo.Get(ctx); err == nil && l != nil {
|
|
if xerr := s.clientFor(l).do(ctx, http.MethodDelete, "/instance/mailboxes/"+m.RemoteID.String(), nil, nil); xerr != nil && xerr.Identifier != "pool_link_mailbox_not_found" {
|
|
if _, rerr := s.repo.Enroll(ctx, accountID, m.RemoteID, false); rerr != nil {
|
|
log.Error().Str("account_id", accountID.String()).Msg("cloud link: cloud unenroll failed and the local row could not be restored")
|
|
}
|
|
return xerr
|
|
}
|
|
}
|
|
s.syncLocalPool(ctx, accountID)
|
|
return nil
|
|
}
|
|
|
|
// RevokeForDelete releases the mailbox on the cloud before local deletion makes
|
|
// retries impossible. It is a leaf, so the email service can call it for a
|
|
// managed mirror without recursing through removeManaged.
|
|
func (s *service) RevokeForDelete(ctx context.Context, orgID, accountID uuid.UUID) *errx.Error {
|
|
if _, xerr := s.ownedAccount(ctx, orgID, accountID); xerr != nil {
|
|
return xerr
|
|
}
|
|
m, err := s.repo.GetByAccount(ctx, accountID)
|
|
if err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
l, err := s.repo.Get(ctx)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("account_id", accountID.String()).Msg("cloud link: link unreadable, so the mailbox's enrollment cannot be revoked")
|
|
return errx.InternalError()
|
|
}
|
|
if l == nil {
|
|
log.Error().Str("account_id", accountID.String()).Msg("cloud link: enrollment exists without a link, so remote revocation cannot be confirmed")
|
|
return errx.InternalError()
|
|
}
|
|
// A revoked link already released every mailbox on the cloud side (RevokeInstance), so Disconnect's mirror deletes succeed.
|
|
if xerr := s.clientFor(l).do(ctx, http.MethodDelete, "/instance/mailboxes/"+m.RemoteID.String(), nil, nil); xerr != nil && xerr.Identifier != "pool_link_mailbox_not_found" && !linkAlreadyGone(xerr) {
|
|
return xerr
|
|
}
|
|
s.forgetToken(accountID)
|
|
// The mailbox delete also removes any stale local enrollment by cascade.
|
|
if err := s.repo.Unenroll(ctx, accountID); err != nil {
|
|
log.Warn().Err(err).Str("account_id", accountID.String()).Msg("cloud link: enrollment revoked but the local row could not be dropped; the mailbox delete removes it")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *service) SetLifecycle(ctx context.Context, orgID, accountID uuid.UUID, action string) (*models.CloudLinkMailboxRow, *errx.Error) {
|
|
if action != "pause" && action != "resume" {
|
|
return nil, errx.NewWithIdentifier(errx.BadRequest, "cloud_link_lifecycle", "Action must be pause or resume.")
|
|
}
|
|
l, xerr := s.link(ctx)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
if _, xerr := s.ownedAccount(ctx, orgID, accountID); xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
m, err := s.repo.GetByAccount(ctx, accountID)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
if m == nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
var state models.PoolLinkMailboxState
|
|
if xerr := s.clientFor(l).do(ctx, http.MethodPatch, "/instance/mailboxes/"+m.RemoteID.String(), models.PoolLinkMailboxPatch{Lifecycle: action}, &state); xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
return s.row(ctx, orgID, accountID)
|
|
}
|