mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-08 16:02:24 +00:00
Merge branch 'main' into fix/web-sentry-dsn-runtime-config
This commit is contained in:
@@ -1473,7 +1473,10 @@ func main() {
|
||||
APIKeys: apiKeyService,
|
||||
Webhooks: webhookServiceForHandler,
|
||||
Subscription: subscriptionService,
|
||||
Segments: segmentService,
|
||||
Forms: formService,
|
||||
Advanced: advancedService,
|
||||
Suppressions: advancedService,
|
||||
FeatureGate: featureGateService,
|
||||
Skills: skillsService,
|
||||
AppBaseURL: cfg.GetStringOptional(ctx, "APP_BASE_URL", "app_base_url", ""),
|
||||
|
||||
@@ -113,6 +113,18 @@ Every tool is gated by its API permission. `tools/list` returns only the tools y
|
||||
| `add_contact` / `delete_contact` | Create or delete a contact | `WRITE_CONTACTS` |
|
||||
| `bulk_edit_contacts` | Tag or subscribe many contacts at once | `BULK_CONTACTS` |
|
||||
| `get_contact_timeline` / `get_contact_sent_emails` | Read a contact's activity and sent mail | `READ_CONTACTS` |
|
||||
| `list_segments` / `get_segment` / `list_segment_fields` | Read saved audiences, and the fields a condition can name | `READ_CONTACTS` |
|
||||
| `preview_segment` | Count what a set of conditions would match, saving nothing | `READ_CONTACTS` |
|
||||
| `create_segment` / `update_segment` / `delete_segment` | Manage saved audiences | `WRITE_CONTACTS` |
|
||||
| `set_segment_members` | Pin contacts into or out of a segment | `WRITE_CONTACTS` |
|
||||
| `list_campaign_segments` | Read the segments feeding a campaign | `READ_CAMPAIGNS` |
|
||||
| `set_campaign_segments` / `add_segment_to_campaign` | Link an audience to a campaign, or enrol its members once | `WRITE_CAMPAIGNS` |
|
||||
| `list_forms` / `get_form` / `list_form_submissions` / `get_form_stats` | Read hosted forms, their submissions, and their funnel | `READ_CONTACTS` |
|
||||
| `create_form` / `update_form` / `delete_form` | Manage hosted forms | `WRITE_CONTACTS` |
|
||||
| `mint_form_link` | Get one contact's personalized form URL | `WRITE_CONTACTS` |
|
||||
| `get_campaign_forms` | How a campaign's forms performed for its recipients | `READ_CAMPAIGNS` |
|
||||
| `list_suppressions` | Read the do-not-contact list, and why each entry is on it | `READ_CONTACTS` |
|
||||
| `add_suppressions` / `remove_suppression` | Suppress an address or domain, or lift one | `WRITE_CONTACTS` |
|
||||
| `list_pipelines` / `list_deals` / `get_deal` / `list_deals_by_contact` | Read CRM pipelines and deals | `READ_CRM` |
|
||||
| `create_task` / `create_deal` | Create CRM records | `WRITE_CRM` |
|
||||
| `update_deal` / `move_deal_stage` / `delete_deal` | Edit, move, or delete a deal | `WRITE_CRM` |
|
||||
@@ -143,6 +155,8 @@ Every tool is gated by its API permission. `tools/list` returns only the tools y
|
||||
| `search_web` / `fetch_url` | Search and read public pages | any |
|
||||
| `load_skill` | Read one of your playbooks | any |
|
||||
|
||||
Two form surfaces are deliberately absent: brand-asset uploads (logo, cover, background) are multipart images with dimension and MIME limits that a tool call cannot express, and the custom forms domain is workspace-wide branding behind a DNS check, so it lives in settings rather than in a tool.
|
||||
|
||||
Rate limits apply to MCP calls exactly as they do to the rest of the API (per-key for API keys, per-user for OAuth tokens). `SEND_CAMPAIGNS` appears in the table because `set_campaign_status` needs it, but that tool is still withheld from MCP: no send-class tool is ever exposed over the MCP transport regardless of scope.
|
||||
|
||||
## How OAuth discovery works
|
||||
|
||||
@@ -78,6 +78,9 @@ Newer builds return the invite-only refusal with its own machine code, `registra
|
||||
| "No mailbox workers are available" when connecting a mailbox | No worker has a heartbeat inside the last 10 minutes. Check `make status` shows `worker` running and `make logs worker` is clean. An empty `ENCRYPTED_KEYS_BACKEND_URL` or worker token lets a worker start and never register, silently |
|
||||
| Connecting a mailbox fails with `SERVER_UNREACHABLE` on a reachable host | The security setting does not match the server. A server expecting STARTTLS looks unreachable to a client attempting implicit TLS, and vice versa. Any port from 1 to 65535 is accepted, so the port alone no longer decides: set **Security** to SSL / TLS for a server that is encrypted from the first byte (usually SMTP `465`, IMAP `993`) and STARTTLS for one that upgrades in place (usually SMTP `587` or `2525`, IMAP `143`) |
|
||||
| A mailbox stalls after about an hour | The worker is missing `BOX_GOOGLE_*` or `BOX_OUTLOOK_*`. The backend starts the OAuth flow but each worker refreshes the token. Set them and restart the worker |
|
||||
| An IMAP mailbox connects but no mail ever arrives | Check the sync card in the mailbox drawer for a folder count. Zero folders on a reachable server means the server refused `STATUS` for every folder; the worker log names each one it skipped. A connected mailbox whose inbox is genuinely empty is normal |
|
||||
| An IMAP mailbox stopped syncing and the log is quiet | Sessions dropped by the server, or by a firewall that removed the mapping without closing the connection, are re-dialed on the next pass, and every pass that cannot reach the server is retried on a widening interval up to five minutes. If a mailbox is still stuck, `make logs worker` shows the folder cursors; a mailbox held by the sync budget says so in its drawer instead |
|
||||
| A folder is missing from the unibox | Up to `100` folders per mailbox are synced (**Instance settings > Limits**). Past that, the inbox and the special folders are kept and the rest follow the server's order, with a warning in the drawer naming how many were left out. Gmail's All Mail, Starred and Important are label views over other folders and are deliberately never synced. A folder can also be skipped when the mail server gives it the same internal id (`UIDVALIDITY`) as another folder, which happens on servers that derive that id from the creation time; the drawer says so, and renaming or recreating the folder gives it a new one |
|
||||
| Scheduled sends never fire | Delayed sends run through the in-process Postgres task poller (`TASKS_PROVIDER=local`), so the backend must be running |
|
||||
| Every send dead-letters with `permission denied` on `/data/blobs` | The `blobs` volume was created before the images owned that path, so it is still `root:root` while the services run as uid 1000. Fix it once with `docker compose -p warmbly exec -u root backend chown -R warmbly:warmbly /data/blobs`. The `blob_fs_root` health check reports it, and volumes created from current images are already correct |
|
||||
| `email account <id> not found in worker` | The mailbox is assigned to a worker that no longer exists, usually because the worker was recreated and came back with a fresh UUID. The reconciler releases and re-places it on a live worker within its interval. Compose workers now keep their id in the `worker_state` volume (`WORKER_STATE_DIR`), so this stops recurring once that volume exists; removing the volume or unsetting `WORKER_STATE_DIR` reintroduces the churn. See [worker identity](/development/deployment-guide/#worker-identity) |
|
||||
|
||||
@@ -13,7 +13,7 @@ Open **Accounts** and choose **Add account**.
|
||||
|----------|--------|-------|
|
||||
| Gmail / Google Workspace | OAuth (`gmail`) | Recommended. No password stored. Sends and syncs over the Gmail API |
|
||||
| Outlook / Microsoft 365 | OAuth (`outlook`) | Recommended. No password stored. Runs on Microsoft Graph |
|
||||
| Any other server | IMAP + SMTP (`smtp_imap`) | Custom domains, self-hosted, or providers without OAuth |
|
||||
| Any other server | IMAP + SMTP (`smtp_imap`) | Custom domains, self-hosted, or providers without OAuth. Any IMAP server works, including Outlook.com, Microsoft 365 over IMAP, Yahoo, Fastmail, Zoho, cPanel and self-hosted Dovecot |
|
||||
|
||||
**OAuth** sends you to your provider's consent screen and returns a token instead of a password. Both OAuth providers use the provider's native API, never IMAP or SMTP, so consent asks to send mail and to read and organize your mailbox. It needs no app passwords or server settings. Note that Google revokes Gmail tokens when the account's password changes, so a password change there means [re-authorizing the mailbox](#reconnecting-an-account) once.
|
||||
|
||||
@@ -35,6 +35,8 @@ The form picks the right one from the port as you type, so standard setups need
|
||||
|
||||
If a mailbox fails to connect with a server-unreachable error and the host and port are definitely right, the security setting is the first thing to check. A server expecting STARTTLS looks unreachable to a client attempting implicit TLS, and vice versa.
|
||||
|
||||
When a mail server goes down or stops answering, the mailbox is not deactivated. Warmbly says so once in the drawer, retries on a widening interval, and picks up where it left off when the server comes back. Nothing that arrived meanwhile is skipped.
|
||||
|
||||
With two-factor authentication on, generate an app password in your provider's security settings and use that.
|
||||
|
||||
<Callout type="warn" title="Authentication runs on connect">
|
||||
@@ -111,6 +113,14 @@ Connecting a mailbox does two things: it imports the mailbox's recent history, a
|
||||
|
||||
**Live sync** then follows every folder the provider exposes, including junk (so placement problems are visible) and sent mail (so a conversation shows both sides). Read state, flags, deletions and moves are mirrored too.
|
||||
|
||||
Nested folders are followed as well, so mail in a subfolder of the inbox or under a label group arrives like anything else. Up to `100` folders per mailbox are synced. Past that the inbox, sent, drafts, spam, trash and archive are always kept and the rest are taken in the order the server lists them, with a note in the mailbox drawer saying how many were left out.
|
||||
|
||||
On IMAP, folders are identified by the standard attributes a server publishes, and by name when it publishes none. The common names are recognized in a dozen languages, so a mailbox whose Sent folder is called "Gesendete Elemente" or "Éléments envoyés" still files sent mail as sent rather than as inbox.
|
||||
|
||||
<Callout type="info" title="Read state on older IMAP servers">
|
||||
Some IMAP servers, including Outlook.com, Microsoft 365 over IMAP and Yahoo, cannot tell a client what changed since it last looked. New mail from those servers still arrives within a minute. Reading or flagging a message in another mail client shows up in Warmbly within about ten minutes rather than immediately. Nothing is lost either way, and Gmail, Outlook over OAuth, Fastmail and most self-hosted servers are immediate.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info" title="Fair use, not a hard cap">
|
||||
There is a budget on how much new mail one mailbox stores per day (`2,000` by default) and how much a whole workspace stores per day (`25,000`), plus a short burst limit so a mailing-list storm cannot swamp the workers. Mail over a budget is not dropped: it waits on the server with the sync cursor held, and comes in when the window rolls. Replies to your own outreach have their own budget and are never held behind ordinary inbound mail. The drawer says "Waiting on the sync budget until ..." while this is happening, and the mailbox checks back less often until then.
|
||||
</Callout>
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -17,8 +18,10 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/app/email"
|
||||
"github.com/warmbly/warmbly/internal/app/emailsend"
|
||||
"github.com/warmbly/warmbly/internal/app/feature"
|
||||
"github.com/warmbly/warmbly/internal/app/form"
|
||||
"github.com/warmbly/warmbly/internal/app/integration"
|
||||
"github.com/warmbly/warmbly/internal/app/organization"
|
||||
"github.com/warmbly/warmbly/internal/app/segment"
|
||||
"github.com/warmbly/warmbly/internal/app/sequence"
|
||||
"github.com/warmbly/warmbly/internal/app/subscription"
|
||||
"github.com/warmbly/warmbly/internal/app/unibox"
|
||||
@@ -68,6 +71,14 @@ type Deps struct {
|
||||
// handlers do (e.g. unibox requires an active trial/paid plan), so a tool
|
||||
// can never read data a 403'd HTTP route would refuse.
|
||||
FeatureGate feature.FeatureGateService
|
||||
// Segments and Forms back the audience and lead-capture tools, both gated
|
||||
// on the contact permissions.
|
||||
Segments segment.Service
|
||||
Forms form.Service
|
||||
// Suppressions edits the do-not-contact list. A second field rather than a
|
||||
// wider Advanced, so a deployment can keep the send guard above without
|
||||
// exposing the list to agents.
|
||||
Suppressions SuppressionManager
|
||||
// Skills backs the load_skill tool (org playbooks). Optional.
|
||||
Skills SkillLookup
|
||||
// Advisor backs the read-only recommendation tools, so the assistant can
|
||||
@@ -87,6 +98,14 @@ type SuppressionChecker interface {
|
||||
ShouldSuppressRecipient(ctx context.Context, orgID uuid.UUID, recipient string) (bool, string, *errx.Error)
|
||||
}
|
||||
|
||||
// SuppressionManager reads and edits the do-not-contact list. *advanced.service
|
||||
// satisfies it, the same value that satisfies SuppressionChecker above.
|
||||
type SuppressionManager interface {
|
||||
ListSuppressions(ctx context.Context, organizationID uuid.UUID, q string, beforeAt *time.Time, beforeID *uuid.UUID, limit int) ([]models.SuppressedRecipient, *errx.Error)
|
||||
AddSuppressions(ctx context.Context, organizationID, actorID uuid.UUID, req *models.AddSuppressionsRequest) (*models.AddSuppressionsResult, *errx.Error)
|
||||
RemoveSuppression(ctx context.Context, organizationID, id uuid.UUID) (*models.SuppressedRecipient, *errx.Error)
|
||||
}
|
||||
|
||||
// SkillLookup returns an enabled org playbook's full content by name (backs the
|
||||
// load_skill tool). *skills.service satisfies it.
|
||||
type SkillLookup interface {
|
||||
@@ -101,6 +120,9 @@ func BuildRegistry(d Deps) *Registry {
|
||||
d.registerCRMTools(r)
|
||||
d.registerCampaignTools(r)
|
||||
d.registerSequenceTools(r)
|
||||
d.registerSegmentTools(r)
|
||||
d.registerFormTools(r)
|
||||
d.registerSuppressionTools(r)
|
||||
d.registerAnalyticsTools(r)
|
||||
d.registerUniboxTools(r)
|
||||
d.registerInboxActionTools(r)
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/generation"
|
||||
)
|
||||
|
||||
// Hosted form tools, gated on the contact permissions like the HTTP routes.
|
||||
// Brand-asset uploads (multipart images) and the DNS-checked forms domain are
|
||||
// deliberately absent.
|
||||
func (d Deps) registerFormTools(r *Registry) {
|
||||
if d.Forms == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// One block. The layout kinds render but collect nothing.
|
||||
fieldItem := objectSchema(map[string]any{
|
||||
"id": strProp("Short lowercase slug, stable across edits; answers are keyed by it."),
|
||||
"type": enumProp("The block kind.", "text", "email", "phone", "textarea", "number", "select", "radio", "checkboxes", "checkbox", "date", "hidden", "heading", "paragraph", "divider", "page_break"),
|
||||
"label": strProp("The visible label. Required for every input block except hidden."),
|
||||
"placeholder": strProp("Optional placeholder text."),
|
||||
"help_text": strProp("Optional hint under the field."),
|
||||
"required": boolProp("Whether an answer is required to submit."),
|
||||
"options": arrProp("The choices, for select, radio and checkboxes.", map[string]any{"type": "string"}),
|
||||
"map_to": enumProp("Fill this contact column with the answer. Without it the answer lands in the contact's custom fields under the label.", "first_name", "last_name", "email", "company", "phone"),
|
||||
"value": strProp("The constant a hidden field submits, or the body text of a paragraph block."),
|
||||
"width": enumProp("full, or half so two fields share a row.", "full", "half"),
|
||||
}, "id", "type")
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "list_forms",
|
||||
Description: "List the workspace's hosted forms with their status, public URL, and view/submission counts.",
|
||||
InputSchema: objectSchema(map[string]any{}),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewContacts,
|
||||
RequiredAPIPerm: models.APIPermReadContacts,
|
||||
Handler: d.listForms,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "get_form",
|
||||
Description: "Get one form: its blocks, design, target campaign, categories, and public URL.",
|
||||
InputSchema: objectSchema(map[string]any{"form_id": strProp("The form's UUID.")}, "form_id"),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewContacts,
|
||||
RequiredAPIPerm: models.APIPermReadContacts,
|
||||
Handler: d.getForm,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "create_form",
|
||||
Description: "Create a form. It starts as an unpublished draft with no blocks; add them with update_form, then publish it by setting status to published.",
|
||||
InputSchema: objectSchema(map[string]any{"name": strProp("Internal name for the form (required).")}, "name"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
RequiredAPIPerm: models.APIPermWriteContacts,
|
||||
Handler: d.createForm,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "update_form",
|
||||
Description: "Update a form. Omitted fields keep their stored value; sending fields replaces the whole block list. Only a published form renders and accepts submissions.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"form_id": strProp("The form's UUID."),
|
||||
"name": strProp("New internal name."),
|
||||
"status": enumProp("draft hides it, published puts it live, archived keeps the data but takes the page offline.", "draft", "published", "archived"),
|
||||
"fields": arrProp("Replacement block list, in display order.", fieldItem),
|
||||
"success_message": strProp("Shown after a successful submit."),
|
||||
"redirect_url": strProp("Send the visitor here instead of showing the success message."),
|
||||
"campaign_id": strProp("Add every contact this form creates to this campaign. Pass an empty string to detach."),
|
||||
"category_ids": arrProp("Categories to file new contacts under.", map[string]any{"type": "string"}),
|
||||
"allowed_domains": arrProp("Domains the form may be embedded on.", map[string]any{"type": "string"}),
|
||||
"captcha_enabled": boolProp("Require a captcha. Has no effect where the install has no captcha provider."),
|
||||
}, "form_id"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
RequiredAPIPerm: models.APIPermWriteContacts,
|
||||
Handler: d.updateForm,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "delete_form",
|
||||
Description: "Delete a form and every submission it captured. The contacts it created are not touched. Requires user approval.",
|
||||
InputSchema: objectSchema(map[string]any{"form_id": strProp("The form's UUID.")}, "form_id"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
RequiredAPIPerm: models.APIPermWriteContacts,
|
||||
Handler: d.deleteForm,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "list_form_submissions",
|
||||
Description: "List a form's submissions, newest first, with the contact each one created or updated.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"form_id": strProp("The form's UUID."),
|
||||
"limit": intProp("Max submissions (1-100, default 50)."),
|
||||
}, "form_id"),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewContacts,
|
||||
RequiredAPIPerm: models.APIPermReadContacts,
|
||||
Handler: d.listFormSubmissions,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "get_form_stats",
|
||||
Description: "Get a form's funnel over a date range: views, starts, submissions, completion rate, per-page drop-off, and breakdowns by source, country, device and campaign.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"form_id": strProp("The form's UUID."),
|
||||
"range": enumProp("The window to report on. Defaults to 30d.", "7d", "30d", "90d"),
|
||||
}, "form_id"),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewContacts,
|
||||
RequiredAPIPerm: models.APIPermReadContacts,
|
||||
Handler: d.getFormStats,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "get_campaign_forms",
|
||||
Description: "Report how the forms linked from a campaign performed for that campaign's recipients: links sent, viewers, starters, submissions.",
|
||||
InputSchema: objectSchema(map[string]any{"campaign_id": strProp("The campaign's UUID.")}, "campaign_id"),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewCampaigns,
|
||||
RequiredAPIPerm: models.APIPermReadCampaigns,
|
||||
Handler: d.getCampaignForms,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "mint_form_link",
|
||||
Description: "Get the personalized form URL for one contact, so a submission is tied back to them. Minting the same link twice returns the same URL.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"form_id": strProp("The form's UUID."),
|
||||
"contact_id": strProp("The contact's UUID."),
|
||||
}, "form_id", "contact_id"),
|
||||
// Idempotent, so it stays out of the approval loop, but it upserts a
|
||||
// ticket row, hence the write permission. Same shape as
|
||||
// get_invitation_link.
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
RequiredAPIPerm: models.APIPermWriteContacts,
|
||||
Handler: d.mintFormLink,
|
||||
})
|
||||
}
|
||||
|
||||
func (d Deps) listForms(ctx context.Context, inv Invocation, _ json.RawMessage) (string, error) {
|
||||
out, xerr := d.Forms.List(ctx, inv.OrgID)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) getForm(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
FormID string `json:"form_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.FormID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, xerr := d.Forms.Get(ctx, inv.OrgID, id)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) createForm(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
Name string `json:"name"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if in.Name == "" {
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
createdBy := inv.UserID
|
||||
out, xerr := d.Forms.Create(ctx, inv.OrgID, &createdBy, in.Name)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
d.logAudit(ctx, inv, models.AuditActionCreate, models.AuditEntityForm, &out.ID, map[string]string{"name": out.Name})
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) updateForm(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
FormID string `json:"form_id"`
|
||||
Name *string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Fields *[]toolFormField `json:"fields"`
|
||||
SuccessMessage *string `json:"success_message"`
|
||||
RedirectURL *string `json:"redirect_url"`
|
||||
CampaignID *string `json:"campaign_id"`
|
||||
CategoryIDs *[]string `json:"category_ids"`
|
||||
AllowedDomains *[]string `json:"allowed_domains"`
|
||||
CaptchaEnabled *bool `json:"captcha_enabled"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.FormID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
write := &models.FormWrite{
|
||||
Name: in.Name,
|
||||
SuccessMessage: in.SuccessMessage,
|
||||
RedirectURL: in.RedirectURL,
|
||||
AllowedDomains: in.AllowedDomains,
|
||||
CaptchaEnabled: in.CaptchaEnabled,
|
||||
}
|
||||
if in.Status != "" {
|
||||
status := models.FormStatus(in.Status)
|
||||
if !status.Valid() {
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
write.Status = &status
|
||||
}
|
||||
if in.Fields != nil {
|
||||
fields, ferr := toFormFields(*in.Fields)
|
||||
if ferr != nil {
|
||||
return "", ferr
|
||||
}
|
||||
write.Fields = &fields
|
||||
}
|
||||
// "" detaches; the nullable wrapper tells "clear it" from "leave it".
|
||||
if in.CampaignID != nil {
|
||||
if *in.CampaignID == "" {
|
||||
write.CampaignID = models.NullableUUID{Set: true}
|
||||
} else {
|
||||
cid, cerr := parseUUIDArg(*in.CampaignID)
|
||||
if cerr != nil {
|
||||
return "", cerr
|
||||
}
|
||||
write.CampaignID = models.NullableUUID{Set: true, Value: &cid}
|
||||
}
|
||||
}
|
||||
if in.CategoryIDs != nil {
|
||||
ids := make([]uuid.UUID, 0, len(*in.CategoryIDs))
|
||||
for _, raw := range *in.CategoryIDs {
|
||||
cid, cerr := parseUUIDArg(raw)
|
||||
if cerr != nil {
|
||||
return "", cerr
|
||||
}
|
||||
ids = append(ids, cid)
|
||||
}
|
||||
write.CategoryIDs = &ids
|
||||
}
|
||||
|
||||
out, xerr := d.Forms.Update(ctx, inv.OrgID, id, write)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityForm, &out.ID, map[string]string{
|
||||
"name": out.Name, "status": string(out.Status),
|
||||
})
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) deleteForm(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
FormID string `json:"form_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.FormID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if xerr := d.Forms.Delete(ctx, inv.OrgID, id); xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
d.logAudit(ctx, inv, models.AuditActionDelete, models.AuditEntityForm, &id, nil)
|
||||
return jsonResult(map[string]any{"ok": true, "form_id": id.String()})
|
||||
}
|
||||
|
||||
func (d Deps) listFormSubmissions(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
FormID string `json:"form_id"`
|
||||
Limit int `json:"limit"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.FormID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
var before *time.Time
|
||||
out, hasMore, xerr := d.Forms.ListSubmissions(ctx, inv.OrgID, id, limit, before)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(map[string]any{"data": out, "has_more": hasMore})
|
||||
}
|
||||
|
||||
func (d Deps) getFormStats(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
FormID string `json:"form_id"`
|
||||
Range string `json:"range"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.FormID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
days := 30
|
||||
switch in.Range {
|
||||
case "", "30d":
|
||||
case "7d":
|
||||
days = 7
|
||||
case "90d":
|
||||
days = 90
|
||||
default:
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
out, xerr := d.Forms.Stats(ctx, inv.OrgID, id, days)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) getCampaignForms(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
CampaignID string `json:"campaign_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cid, err := parseUUIDArg(in.CampaignID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, xerr := d.Forms.CampaignForms(ctx, inv.OrgID, cid)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) mintFormLink(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
FormID string `json:"form_id"`
|
||||
ContactID string `json:"contact_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fid, err := parseUUIDArg(in.FormID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cid, err := parseUUIDArg(in.ContactID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
url, xerr := d.Forms.MintLink(ctx, inv.OrgID, fid, cid)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(map[string]any{"url": url})
|
||||
}
|
||||
|
||||
// Model-facing block shape, kept off the stored struct's json tags.
|
||||
type toolFormField struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Placeholder string `json:"placeholder"`
|
||||
HelpText string `json:"help_text"`
|
||||
Required bool `json:"required"`
|
||||
Options []string `json:"options"`
|
||||
MapTo string `json:"map_to"`
|
||||
Value string `json:"value"`
|
||||
Width string `json:"width"`
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
|
||||
// The service does the real validation; this only rejects an unknown block
|
||||
// type, so a bad one is a correctable argument error, not a round-trip.
|
||||
func toFormFields(in []toolFormField) ([]models.FormField, error) {
|
||||
out := make([]models.FormField, 0, len(in))
|
||||
for _, f := range in {
|
||||
ft := models.FormFieldType(f.Type)
|
||||
if !ft.Valid() {
|
||||
return nil, ErrInvalidArgs
|
||||
}
|
||||
out = append(out, models.FormField{
|
||||
ID: f.ID,
|
||||
Type: ft,
|
||||
Label: f.Label,
|
||||
Placeholder: f.Placeholder,
|
||||
HelpText: f.HelpText,
|
||||
Required: f.Required,
|
||||
Options: f.Options,
|
||||
MapTo: f.MapTo,
|
||||
Value: f.Value,
|
||||
Width: f.Width,
|
||||
Rows: f.Rows,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/app/form"
|
||||
"github.com/warmbly/warmbly/internal/app/segment"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/generation"
|
||||
)
|
||||
|
||||
// Registration only closes over these values, it never calls them, so
|
||||
// embedding the interface beats stubbing thirty methods. An accidental call
|
||||
// panics rather than passing quietly.
|
||||
type stubSegments struct{ segment.Service }
|
||||
type stubForms struct{ form.Service }
|
||||
type stubSuppressions struct{ SuppressionManager }
|
||||
|
||||
func leadSurfaceRegistry() *Registry {
|
||||
return BuildRegistry(Deps{
|
||||
Segments: stubSegments{},
|
||||
Forms: stubForms{},
|
||||
Suppressions: stubSuppressions{},
|
||||
})
|
||||
}
|
||||
|
||||
// Every tool these three surfaces add, with the gate it must carry. A wrong
|
||||
// permission here is a tool that reads or writes more than the caller may.
|
||||
var leadSurfaceTools = map[string]struct {
|
||||
risk generation.RiskClass
|
||||
orgPerm models.OrganizationPermission
|
||||
apiPerm uint64
|
||||
}{
|
||||
// Linking an audience is a campaign write; the rest are contact-gated.
|
||||
"list_segments": {generation.RiskRead, models.PermViewContacts, models.APIPermReadContacts},
|
||||
"get_segment": {generation.RiskRead, models.PermViewContacts, models.APIPermReadContacts},
|
||||
"list_segment_fields": {generation.RiskRead, models.PermViewContacts, models.APIPermReadContacts},
|
||||
"preview_segment": {generation.RiskRead, models.PermViewContacts, models.APIPermReadContacts},
|
||||
"create_segment": {generation.RiskWrite, models.PermManageContacts, models.APIPermWriteContacts},
|
||||
"update_segment": {generation.RiskWrite, models.PermManageContacts, models.APIPermWriteContacts},
|
||||
"delete_segment": {generation.RiskWrite, models.PermManageContacts, models.APIPermWriteContacts},
|
||||
"set_segment_members": {generation.RiskWrite, models.PermManageContacts, models.APIPermWriteContacts},
|
||||
"list_campaign_segments": {generation.RiskRead, models.PermViewCampaigns, models.APIPermReadCampaigns},
|
||||
"set_campaign_segments": {generation.RiskWrite, models.PermManageCampaigns, models.APIPermWriteCampaigns},
|
||||
"add_segment_to_campaign": {generation.RiskWrite, models.PermManageCampaigns, models.APIPermWriteCampaigns},
|
||||
|
||||
// mint_form_link writes a ticket row, so it keeps the write permission.
|
||||
"list_forms": {generation.RiskRead, models.PermViewContacts, models.APIPermReadContacts},
|
||||
"get_form": {generation.RiskRead, models.PermViewContacts, models.APIPermReadContacts},
|
||||
"create_form": {generation.RiskWrite, models.PermManageContacts, models.APIPermWriteContacts},
|
||||
"update_form": {generation.RiskWrite, models.PermManageContacts, models.APIPermWriteContacts},
|
||||
"delete_form": {generation.RiskWrite, models.PermManageContacts, models.APIPermWriteContacts},
|
||||
"list_form_submissions": {generation.RiskRead, models.PermViewContacts, models.APIPermReadContacts},
|
||||
"get_form_stats": {generation.RiskRead, models.PermViewContacts, models.APIPermReadContacts},
|
||||
"get_campaign_forms": {generation.RiskRead, models.PermViewCampaigns, models.APIPermReadCampaigns},
|
||||
"mint_form_link": {generation.RiskRead, models.PermManageContacts, models.APIPermWriteContacts},
|
||||
|
||||
// Changing who is unreachable decides who gets mail.
|
||||
"list_suppressions": {generation.RiskRead, models.PermViewContacts, models.APIPermReadContacts},
|
||||
"add_suppressions": {generation.RiskWrite, models.PermManageContacts, models.APIPermWriteContacts},
|
||||
"remove_suppression": {generation.RiskWrite, models.PermManageContacts, models.APIPermWriteContacts},
|
||||
}
|
||||
|
||||
func TestLeadSurfaceToolsRegisterWithTheirGates(t *testing.T) {
|
||||
r := leadSurfaceRegistry()
|
||||
for name, want := range leadSurfaceTools {
|
||||
tool, ok := r.Get(name)
|
||||
if !ok {
|
||||
t.Errorf("%s: not registered", name)
|
||||
continue
|
||||
}
|
||||
if tool.Risk != want.risk {
|
||||
t.Errorf("%s: risk = %v, want %v", name, tool.Risk, want.risk)
|
||||
}
|
||||
if tool.RequiredOrgPerm != want.orgPerm {
|
||||
t.Errorf("%s: org perm = %v, want %v", name, tool.RequiredOrgPerm, want.orgPerm)
|
||||
}
|
||||
if tool.RequiredAPIPerm != want.apiPerm {
|
||||
t.Errorf("%s: api perm = %v, want %v", name, tool.RequiredAPIPerm, want.apiPerm)
|
||||
}
|
||||
if tool.Handler == nil {
|
||||
t.Errorf("%s: nil handler", name)
|
||||
}
|
||||
if tool.InputSchema == nil {
|
||||
t.Errorf("%s: nil input schema", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A send-class tool is hidden from both /ai/tools and MCP, which would make
|
||||
// adding these pointless. None of them transmit mail.
|
||||
func TestLeadSurfaceToolsAreNeverSendClass(t *testing.T) {
|
||||
r := leadSurfaceRegistry()
|
||||
for name := range leadSurfaceTools {
|
||||
if tool, ok := r.Get(name); ok && tool.Risk == generation.RiskSend {
|
||||
t.Errorf("%s is send-class, so no agent surface will ever expose it", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No service wired means no tool registered, rather than one that panics.
|
||||
func TestLeadSurfaceToolsSkippedWithoutServices(t *testing.T) {
|
||||
r := BuildRegistry(Deps{})
|
||||
for name := range leadSurfaceTools {
|
||||
if _, ok := r.Get(name); ok {
|
||||
t.Errorf("%s registered with no backing service", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A contacts-read key sees the reads and none of the writes.
|
||||
func TestLeadSurfaceToolsRespectAReadOnlyKey(t *testing.T) {
|
||||
r := leadSurfaceRegistry()
|
||||
inv := Invocation{IsAPIKey: true, APIPerms: models.APIPermReadContacts}
|
||||
|
||||
permitted := map[string]bool{}
|
||||
for _, tool := range r.PermittedTools(inv) {
|
||||
permitted[tool.Name] = true
|
||||
}
|
||||
|
||||
if !permitted["list_segments"] {
|
||||
t.Error("a contacts-read key should see list_segments")
|
||||
}
|
||||
if !permitted["list_suppressions"] {
|
||||
t.Error("a contacts-read key should see list_suppressions")
|
||||
}
|
||||
for _, name := range []string{"create_segment", "delete_form", "remove_suppression", "mint_form_link"} {
|
||||
if permitted[name] {
|
||||
t.Errorf("%s reached a read-only key", name)
|
||||
}
|
||||
}
|
||||
// A contacts key is not a campaigns key.
|
||||
if permitted["set_campaign_segments"] {
|
||||
t.Error("set_campaign_segments reached a key with no campaign write bit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentMatchDefaultsToAllAndRejectsJunk(t *testing.T) {
|
||||
got, err := segmentMatch("")
|
||||
if err != nil || got != models.SegmentMatchAll {
|
||||
t.Errorf(`segmentMatch("") = %q, %v; want "all", nil`, got, err)
|
||||
}
|
||||
if got, err := segmentMatch("any"); err != nil || got != models.SegmentMatchAny {
|
||||
t.Errorf(`segmentMatch("any") = %q, %v; want "any", nil`, got, err)
|
||||
}
|
||||
// Reading an unknown mode as "all" could widen an audience to everyone.
|
||||
if _, err := segmentMatch("either"); err == nil {
|
||||
t.Error("segmentMatch accepted an unknown match mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToSegmentConditionsCarriesBothValueShapes(t *testing.T) {
|
||||
got := toSegmentConditions([]toolCondition{
|
||||
{Field: "email_domain", Operator: "ends_with", Value: "acme.com"},
|
||||
{Field: "esp_provider", Operator: "in", Values: []string{"gmail", "outlook"}},
|
||||
})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d conditions, want 2", len(got))
|
||||
}
|
||||
if got[0].Value != "acme.com" || got[0].Values != nil {
|
||||
t.Errorf("scalar condition mangled: %+v", got[0])
|
||||
}
|
||||
if len(got[1].Values) != 2 || got[1].Value != "" {
|
||||
t.Errorf("list condition mangled: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFormFieldsRejectsAnUnknownBlockType(t *testing.T) {
|
||||
if _, err := toFormFields([]toolFormField{{ID: "a", Type: "email"}}); err != nil {
|
||||
t.Fatalf("valid block rejected: %v", err)
|
||||
}
|
||||
if _, err := toFormFields([]toolFormField{{ID: "a", Type: "signature_pad"}}); err == nil {
|
||||
t.Error("toFormFields accepted a block type the builder cannot render")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/generation"
|
||||
)
|
||||
|
||||
// Segment tools, gated on the contact permissions like the HTTP routes, except
|
||||
// the two campaign-linking ones: attaching an audience is a campaign write.
|
||||
func (d Deps) registerSegmentTools(r *Registry) {
|
||||
if d.Segments == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Shared by create, update and preview.
|
||||
conditionItem := objectSchema(map[string]any{
|
||||
"field": strProp("Field name from list_segment_fields, or \"custom.<key>\" for a custom field."),
|
||||
"operator": strProp("An operator that field's kind accepts, from list_segment_fields."),
|
||||
"value": strProp("The value, for scalar operators. A date field takes YYYY-MM-DD; within_days takes a whole number of days."),
|
||||
"values": arrProp("The values, for the list operators in / not_in.", map[string]any{"type": "string"}),
|
||||
}, "field", "operator")
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "list_segments",
|
||||
Description: "List the workspace's saved contact segments with their live membership counts.",
|
||||
InputSchema: objectSchema(map[string]any{}),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewContacts,
|
||||
RequiredAPIPerm: models.APIPermReadContacts,
|
||||
Handler: d.listSegments,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "get_segment",
|
||||
Description: "Get one segment: its conditions, match mode, and how many contacts it currently holds.",
|
||||
InputSchema: objectSchema(map[string]any{"segment_id": strProp("The segment's UUID.")}, "segment_id"),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewContacts,
|
||||
RequiredAPIPerm: models.APIPermReadContacts,
|
||||
Handler: d.getSegment,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "list_segment_fields",
|
||||
Description: "List every field a segment condition can name, with its kind and the operators it accepts. Call this before building conditions: the vocabulary includes this workspace's own custom fields and cannot be guessed.",
|
||||
InputSchema: objectSchema(map[string]any{}),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewContacts,
|
||||
RequiredAPIPerm: models.APIPermReadContacts,
|
||||
Handler: d.listSegmentFields,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "preview_segment",
|
||||
Description: "Count the contacts a set of conditions would match, without saving anything. Use it to check an audience is the size you expect before creating the segment.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"match": enumProp("Whether every condition must hold, or any one of them. Defaults to all.", "all", "any"),
|
||||
"conditions": arrProp("The conditions to evaluate.", conditionItem),
|
||||
"segment_id": strProp("An existing segment whose manual overrides should be kept in the count, when previewing an edit to it."),
|
||||
}, "conditions"),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewContacts,
|
||||
RequiredAPIPerm: models.APIPermReadContacts,
|
||||
Handler: d.previewSegment,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "create_segment",
|
||||
Description: "Create a saved contact segment. Membership is evaluated live, so the segment keeps up with the contacts on its own.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"name": strProp("A short name for the audience (required)."),
|
||||
"description": strProp("Optional longer description."),
|
||||
"color": strProp("Optional #rrggbb swatch for the dashboard."),
|
||||
"match": enumProp("Whether every condition must hold, or any one of them. Defaults to all.", "all", "any"),
|
||||
"conditions": arrProp("The membership conditions.", conditionItem),
|
||||
}, "name", "conditions"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
RequiredAPIPerm: models.APIPermWriteContacts,
|
||||
Handler: d.createSegment,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "update_segment",
|
||||
Description: "Update a segment's name, description, colour, match mode, or conditions. Omitted fields keep their stored value; sending conditions replaces the whole list.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"segment_id": strProp("The segment's UUID."),
|
||||
"name": strProp("New name."),
|
||||
"description": strProp("New description."),
|
||||
"color": strProp("New #rrggbb swatch."),
|
||||
"match": enumProp("New match mode.", "all", "any"),
|
||||
"conditions": arrProp("Replacement condition list.", conditionItem),
|
||||
}, "segment_id"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
RequiredAPIPerm: models.APIPermWriteContacts,
|
||||
Handler: d.updateSegment,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "delete_segment",
|
||||
Description: "Delete a segment. The contacts in it are not touched, but any campaign fed by it stops receiving new members. Requires user approval.",
|
||||
InputSchema: objectSchema(map[string]any{"segment_id": strProp("The segment's UUID.")}, "segment_id"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
RequiredAPIPerm: models.APIPermWriteContacts,
|
||||
Handler: d.deleteSegment,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "set_segment_members",
|
||||
Description: "Pin contacts into or out of a segment regardless of what its conditions say, or clear that override so the conditions decide again.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"segment_id": strProp("The segment's UUID."),
|
||||
"contacts": arrProp("Contact UUIDs to override.", map[string]any{"type": "string"}),
|
||||
"mode": enumProp("include pins them in, exclude pins them out, auto clears the override.", "include", "exclude", "auto"),
|
||||
}, "segment_id", "contacts", "mode"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
RequiredAPIPerm: models.APIPermWriteContacts,
|
||||
Handler: d.setSegmentMembers,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "list_campaign_segments",
|
||||
Description: "List the segments feeding a campaign, with how many of each one's members are already leads and how many are held out.",
|
||||
InputSchema: objectSchema(map[string]any{"campaign_id": strProp("The campaign's UUID.")}, "campaign_id"),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewCampaigns,
|
||||
RequiredAPIPerm: models.APIPermReadCampaigns,
|
||||
Handler: d.listCampaignSegments,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "set_campaign_segments",
|
||||
Description: "Replace the set of segments feeding a campaign. A linked segment is a live audience source: its members are enrolled as leads now and kept enrolled as the segment changes. This is the whole set, so pass every segment you want attached, or an empty list to detach them all. Requires user approval.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"campaign_id": strProp("The campaign's UUID."),
|
||||
"segment_ids": arrProp("The segment UUIDs to link, at most 20. An empty list detaches every segment.", map[string]any{"type": "string"}),
|
||||
}, "campaign_id", "segment_ids"),
|
||||
Risk: generation.RiskWrite,
|
||||
// Linking an audience changes who a campaign mails.
|
||||
RequiredOrgPerm: models.PermManageCampaigns,
|
||||
RequiredAPIPerm: models.APIPermWriteCampaigns,
|
||||
Handler: d.setCampaignSegments,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "add_segment_to_campaign",
|
||||
Description: "Enrol a segment's current members as leads of a campaign, once. This is a one-off copy: later members are NOT added. Use set_campaign_segments instead to keep the audience live. Requires user approval.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"segment_id": strProp("The segment's UUID."),
|
||||
"campaign_id": strProp("The campaign to enrol them into."),
|
||||
}, "segment_id", "campaign_id"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageCampaigns,
|
||||
RequiredAPIPerm: models.APIPermWriteCampaigns,
|
||||
Handler: d.addSegmentToCampaign,
|
||||
})
|
||||
}
|
||||
|
||||
// Model-facing condition shape, kept off the stored struct's json tags.
|
||||
type toolCondition struct {
|
||||
Field string `json:"field"`
|
||||
Operator string `json:"operator"`
|
||||
Value string `json:"value"`
|
||||
Values []string `json:"values"`
|
||||
}
|
||||
|
||||
func toSegmentConditions(in []toolCondition) []models.SegmentCondition {
|
||||
out := make([]models.SegmentCondition, 0, len(in))
|
||||
for _, c := range in {
|
||||
out = append(out, models.SegmentCondition{
|
||||
Field: c.Field,
|
||||
Operator: c.Operator,
|
||||
Value: c.Value,
|
||||
Values: c.Values,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Defaults to "all"; an unknown mode is rejected, since "any" can differ by the
|
||||
// whole audience.
|
||||
func segmentMatch(s string) (models.SegmentMatch, error) {
|
||||
switch s {
|
||||
case "":
|
||||
return models.SegmentMatchAll, nil
|
||||
case string(models.SegmentMatchAll), string(models.SegmentMatchAny):
|
||||
return models.SegmentMatch(s), nil
|
||||
default:
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
}
|
||||
|
||||
func (d Deps) listSegments(ctx context.Context, inv Invocation, _ json.RawMessage) (string, error) {
|
||||
out, xerr := d.Segments.List(ctx, inv.OrgID)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) getSegment(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
SegmentID string `json:"segment_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.SegmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, xerr := d.Segments.Get(ctx, inv.OrgID, id)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) listSegmentFields(ctx context.Context, inv Invocation, _ json.RawMessage) (string, error) {
|
||||
out, xerr := d.Segments.Fields(ctx, inv.OrgID)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) previewSegment(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
Match string `json:"match"`
|
||||
Conditions []toolCondition `json:"conditions"`
|
||||
SegmentID string `json:"segment_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
match, err := segmentMatch(in.Match)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req := &models.SegmentPreview{Match: match, Conditions: toSegmentConditions(in.Conditions)}
|
||||
if in.SegmentID != "" {
|
||||
id, perr := parseUUIDArg(in.SegmentID)
|
||||
if perr != nil {
|
||||
return "", perr
|
||||
}
|
||||
req.ID = &id
|
||||
}
|
||||
n, xerr := d.Segments.Preview(ctx, inv.OrgID, req)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(map[string]any{"contact_count": n})
|
||||
}
|
||||
|
||||
func (d Deps) createSegment(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Color *string `json:"color"`
|
||||
Match string `json:"match"`
|
||||
Conditions []toolCondition `json:"conditions"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if in.Name == "" {
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
match, err := segmentMatch(in.Match)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conds := toSegmentConditions(in.Conditions)
|
||||
createdBy := inv.UserID
|
||||
out, xerr := d.Segments.Create(ctx, inv.OrgID, &createdBy, &models.SegmentWrite{
|
||||
Name: &in.Name,
|
||||
Description: in.Description,
|
||||
Color: in.Color,
|
||||
Match: &match,
|
||||
Conditions: &conds,
|
||||
})
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
d.logAudit(ctx, inv, models.AuditActionCreate, models.AuditEntitySegment, &out.ID, map[string]string{"name": out.Name})
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) updateSegment(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
SegmentID string `json:"segment_id"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Color *string `json:"color"`
|
||||
Match string `json:"match"`
|
||||
Conditions *[]toolCondition `json:"conditions"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.SegmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
write := &models.SegmentWrite{Name: in.Name, Description: in.Description, Color: in.Color}
|
||||
if in.Match != "" {
|
||||
match, merr := segmentMatch(in.Match)
|
||||
if merr != nil {
|
||||
return "", merr
|
||||
}
|
||||
write.Match = &match
|
||||
}
|
||||
if in.Conditions != nil {
|
||||
conds := toSegmentConditions(*in.Conditions)
|
||||
write.Conditions = &conds
|
||||
}
|
||||
out, xerr := d.Segments.Update(ctx, inv.OrgID, id, write)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntitySegment, &out.ID, map[string]string{"name": out.Name})
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) deleteSegment(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
SegmentID string `json:"segment_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.SegmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if xerr := d.Segments.Delete(ctx, inv.OrgID, id); xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
d.logAudit(ctx, inv, models.AuditActionDelete, models.AuditEntitySegment, &id, nil)
|
||||
return jsonResult(map[string]any{"ok": true, "segment_id": id.String()})
|
||||
}
|
||||
|
||||
func (d Deps) setSegmentMembers(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
SegmentID string `json:"segment_id"`
|
||||
Contacts []string `json:"contacts"`
|
||||
Mode string `json:"mode"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.SegmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode := models.SegmentMemberMode(in.Mode)
|
||||
switch mode {
|
||||
case models.SegmentMemberInclude, models.SegmentMemberExclude, models.SegmentMemberAuto:
|
||||
default:
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
if len(in.Contacts) == 0 {
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
n, xerr := d.Segments.SetMembers(ctx, inv.OrgID, id, &models.SegmentMembersWrite{
|
||||
Contacts: in.Contacts,
|
||||
Mode: mode,
|
||||
})
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntitySegment, &id, map[string]string{"members": in.Mode})
|
||||
return jsonResult(map[string]any{"updated": n})
|
||||
}
|
||||
|
||||
func (d Deps) listCampaignSegments(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
CampaignID string `json:"campaign_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cid, err := parseUUIDArg(in.CampaignID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, xerr := d.Segments.ListCampaignSegments(ctx, inv.OrgID, cid)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (d Deps) setCampaignSegments(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
// Pointer so an omitted key is told apart from an explicit [], which means
|
||||
// detach everything.
|
||||
in, err := decodeArgs[struct {
|
||||
CampaignID string `json:"campaign_id"`
|
||||
SegmentIDs *[]string `json:"segment_ids"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cid, err := parseUUIDArg(in.CampaignID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if in.SegmentIDs == nil {
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
links, added, xerr := d.Segments.SetCampaignSegments(ctx, inv.OrgID, cid, &models.CampaignSegmentsWrite{
|
||||
SegmentIDs: *in.SegmentIDs,
|
||||
})
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityCampaign, &cid, map[string]string{
|
||||
"segments": strconv.Itoa(len(links)), "added": strconv.Itoa(added),
|
||||
})
|
||||
return jsonResult(map[string]any{"data": links, "added": added})
|
||||
}
|
||||
|
||||
func (d Deps) addSegmentToCampaign(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
SegmentID string `json:"segment_id"`
|
||||
CampaignID string `json:"campaign_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sid, err := parseUUIDArg(in.SegmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, perr := parseUUIDArg(in.CampaignID); perr != nil {
|
||||
return "", perr
|
||||
}
|
||||
res, xerr := d.Segments.AddToCampaign(ctx, inv.OrgID, inv.UserID.String(), sid, &models.SegmentAddToCampaign{
|
||||
CampaignID: in.CampaignID,
|
||||
})
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
cid := res.CampaignID
|
||||
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityCampaign, &cid, map[string]string{
|
||||
"segment_id": sid.String(), "added": strconv.Itoa(res.Added),
|
||||
})
|
||||
return jsonResult(res)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/generation"
|
||||
)
|
||||
|
||||
// Suppression-list tools. Reading is a contacts read; adding or lifting an
|
||||
// entry decides who gets mail, so it takes the contacts write bit.
|
||||
func (d Deps) registerSuppressionTools(r *Registry) {
|
||||
if d.Suppressions == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "list_suppressions",
|
||||
Description: "List the workspace suppression list: the addresses and domains campaign mail is never sent to, and why each one is there. Use it to explain why a contact is not receiving mail.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"q": strProp("Only entries whose address or domain contains this text."),
|
||||
"limit": intProp("Max entries (1-200, default 50)."),
|
||||
}),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewContacts,
|
||||
RequiredAPIPerm: models.APIPermReadContacts,
|
||||
Handler: d.listSuppressions,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "add_suppressions",
|
||||
Description: "Add addresses or whole domains to the suppression list, so campaigns stop mailing them. A domain entry matches every address at it. Requires user approval.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"values": arrProp("Addresses, or bare domains with or without a leading @.", map[string]any{"type": "string"}),
|
||||
"reason": strProp("Why they are being suppressed, recorded on every entry."),
|
||||
}, "values"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
RequiredAPIPerm: models.APIPermWriteContacts,
|
||||
Handler: d.addSuppressions,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "remove_suppression",
|
||||
Description: "Lift one suppression, letting campaigns mail that recipient again. Check why it was suppressed first: undoing somebody's own unsubscribe or a spam complaint is not something to do on a guess. Requires user approval.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"suppression_id": strProp("The suppression's UUID, from list_suppressions."),
|
||||
}, "suppression_id"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
RequiredAPIPerm: models.APIPermWriteContacts,
|
||||
Handler: d.removeSuppression,
|
||||
})
|
||||
}
|
||||
|
||||
func (d Deps) listSuppressions(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
Q string `json:"q"`
|
||||
Limit int `json:"limit"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, xerr := d.Suppressions.ListSuppressions(ctx, inv.OrgID, in.Q, nil, nil, limit)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
return jsonResult(map[string]any{"data": rows})
|
||||
}
|
||||
|
||||
func (d Deps) addSuppressions(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
Values []string `json:"values"`
|
||||
Reason string `json:"reason"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(in.Values) == 0 {
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
req := &models.AddSuppressionsRequest{Reason: in.Reason}
|
||||
for _, v := range in.Values {
|
||||
req.Entries = append(req.Entries, models.SuppressionEntry{Value: v})
|
||||
}
|
||||
res, xerr := d.Suppressions.AddSuppressions(ctx, inv.OrgID, inv.UserID, req)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
d.logAudit(ctx, inv, models.AuditActionCreate, models.AuditEntitySuppression, nil, map[string]string{
|
||||
"added": strconv.Itoa(res.Added), "skipped": strconv.Itoa(len(res.Skipped)),
|
||||
})
|
||||
return jsonResult(res)
|
||||
}
|
||||
|
||||
func (d Deps) removeSuppression(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
SuppressionID string `json:"suppression_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.SuppressionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
entry, xerr := d.Suppressions.RemoveSuppression(ctx, inv.OrgID, id)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
// The row is gone, so what it held travels in the audit entry.
|
||||
d.logAudit(ctx, inv, models.AuditActionDelete, models.AuditEntitySuppression, &id, map[string]string{
|
||||
"value": entry.Email, "kind": string(entry.Kind), "source": string(entry.Source),
|
||||
})
|
||||
return jsonResult(map[string]any{"ok": true, "removed": entry})
|
||||
}
|
||||
@@ -75,7 +75,12 @@ func mailErrorToJobEventType(mailErr *errx.MailError) models.JobEventType {
|
||||
case errx.MailErrorCodeServerUnreachable,
|
||||
errx.MailErrorCodeConnectionLost,
|
||||
errx.MailErrorCodeNotFound,
|
||||
errx.MailErrorCodeImapUnknown:
|
||||
errx.MailErrorCodeImapUnknown,
|
||||
// The mailbox has more folders than the sync follows, or two of them
|
||||
// share an id. Warnings, not failures: the inbox and the special
|
||||
// folders are always among the ones kept, so mail keeps arriving.
|
||||
errx.MailErrorCodeFolderLimit,
|
||||
errx.MailErrorCodeFolderConflict:
|
||||
return models.JobEventTypeEmailServerError
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -17,9 +17,18 @@ import (
|
||||
type ImapConn interface {
|
||||
// Sync pass.
|
||||
Folders() ([]models.Mailbox, *errx.MailError)
|
||||
// FolderOverflow is how many folders the last listing left out for the
|
||||
// cap, FolderConflicts how many it left out for a duplicate UIDVALIDITY.
|
||||
FolderOverflow() int
|
||||
FolderConflicts() int
|
||||
// HasCondStore picks the incremental strategy: mod-sequences when the
|
||||
// server has CONDSTORE, UIDNEXT plus a periodic flag scan when it does not.
|
||||
HasCondStore() bool
|
||||
ReleaseMailbox()
|
||||
SelectForSync(mailbox string) (uint32, *errx.MailError)
|
||||
SearchChangedSince(modSeq uint64) ([]goimap.UID, *errx.MailError)
|
||||
SearchNewSince(uidNext uint32) ([]goimap.UID, *errx.MailError)
|
||||
FetchFlags(ctx context.Context, uidFrom uint32) (map[uint32]imap.FlagState, *errx.MailError)
|
||||
SearchSince(since time.Time) ([]goimap.UID, *errx.MailError)
|
||||
FetchEnvelopes(ctx context.Context, uids []goimap.UID) ([]*imap.Fetched, *errx.MailError)
|
||||
FetchBody(f *imap.Fetched)
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
)
|
||||
|
||||
// syncBackoffMax is the longest a mailbox waits between passes: while fair
|
||||
// use holds it, or after the provider itself asked us to slow down.
|
||||
// use holds it, after the provider asked us to slow down, or while the mail
|
||||
// server is unreachable.
|
||||
const syncBackoffMax = 5 * time.Minute
|
||||
|
||||
// StartSyncWorker runs the mail sync loop until the context is cancelled.
|
||||
@@ -43,10 +44,17 @@ func (w *WMail) StartSyncWorker(ctx context.Context) {
|
||||
// nextSyncDelay picks the wait before the next pass.
|
||||
func (w *WMail) nextSyncDelay(base time.Duration, last *errx.MailError) time.Duration {
|
||||
d := base
|
||||
if last != nil && last.Code == errx.MailErrorCodeSendingTooFast {
|
||||
switch {
|
||||
case last != nil && last.Code == errx.MailErrorCodeSendingTooFast:
|
||||
// The provider returned 429: back off well past the base interval.
|
||||
d = syncBackoffMax
|
||||
} else if w.tracker != nil && w.tracker.state.ThrottledUntil != nil {
|
||||
case last != nil && isTransportError(last):
|
||||
// The server is unreachable. Retry soon after the first failure (a
|
||||
// dropped session reconnects on the next pass and costs one dial),
|
||||
// then step back toward the ceiling while it stays down, so a server
|
||||
// that is out for a day does not write a warning every minute.
|
||||
d = min(base<<min(w.transportFailures, 8), syncBackoffMax)
|
||||
case w.tracker != nil && w.tracker.state.ThrottledUntil != nil:
|
||||
// Held by fair use: no point asking every minute; wake when the
|
||||
// window rolls, bounded so a priority reply still lands promptly.
|
||||
if until := time.Until(*w.tracker.state.ThrottledUntil); until > d {
|
||||
@@ -74,9 +82,34 @@ func (w *WMail) syncOnce(ctx context.Context) (result *errx.MailError) {
|
||||
}
|
||||
}()
|
||||
if err := w.SyncMail(ctx); err != nil {
|
||||
// A server that is down answers every pass the same way. Report the
|
||||
// first one and then stay quiet until it comes back, so one outage is
|
||||
// one warning in the drawer rather than one a minute.
|
||||
if isTransportError(err) {
|
||||
w.transportFailures++
|
||||
if w.transportFailures > 1 {
|
||||
log.Debug().Err(err).Str("email_id", w.ID.String()).Int("consecutive", w.transportFailures).Msg("mail server still unreachable")
|
||||
return err
|
||||
}
|
||||
}
|
||||
w.CaptureError(err)
|
||||
log.Warn().Err(err).Str("email_id", w.ID.String()).Msg("mail sync error")
|
||||
return err
|
||||
}
|
||||
w.transportFailures = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTransportError is the mail server being unreachable rather than refusing
|
||||
// what we asked: a dropped session, a refused dial, a timeout. These retry on
|
||||
// their own and must not be treated as a mailbox problem.
|
||||
func isTransportError(err *errx.MailError) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
switch err.Code {
|
||||
case errx.MailErrorCodeServerUnreachable, errx.MailErrorCodeConnectionLost:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
goimap "github.com/emersion/go-imap/v2"
|
||||
@@ -40,16 +39,23 @@ func (w *WMail) Sync(ctx context.Context) *errx.MailError {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Dropped here, not skipped below, so a label view a previous build
|
||||
// baselined falls into the deletion sweep and its cursor is retired.
|
||||
w.reportFolderOverflow()
|
||||
// Folders() already drops Gmail's label views. Dropping them here too
|
||||
// costs nothing and keeps the pass correct against any listing: a view
|
||||
// that reached it would re-file known mail as archive under a second UID.
|
||||
folders = slices.DeleteFunc(folders, func(b models.Mailbox) bool { return imapVirtualFolder(&b) })
|
||||
|
||||
// condStore decides the incremental strategy for the whole account:
|
||||
// mod-sequences where the server has CONDSTORE, UIDNEXT where it does not
|
||||
// (Outlook.com, Microsoft 365 over IMAP, Yahoo, many hosted servers).
|
||||
condStore := client.HasCondStore()
|
||||
|
||||
for i := range folders {
|
||||
box := &folders[i]
|
||||
befBox := w.SmtpImapData.FindPair(box)
|
||||
if befBox == nil {
|
||||
// First sight: baseline. Live sync starts from this mod-sequence;
|
||||
// the backfill owns everything before it.
|
||||
// First sight: baseline. Live sync starts from this cursor; the
|
||||
// backfill owns everything before it.
|
||||
saved := *box
|
||||
if err := w.mboxEvent(&saved); err != nil {
|
||||
return nil
|
||||
@@ -58,26 +64,28 @@ func (w *WMail) Sync(ctx context.Context) *errx.MailError {
|
||||
continue
|
||||
}
|
||||
|
||||
changed := imapFolderChanged(befBox, box, condStore)
|
||||
fullyProcessed := true
|
||||
if befBox.HighestModSeq != box.HighestModSeq && !stats.aborted {
|
||||
if changed && !stats.aborted {
|
||||
w.SmtpImapData.mailbox = box.UIDValidity
|
||||
w.SmtpImapData.folder = imapCanonicalFolder(box)
|
||||
done, err := w.imapIncremental(ctx, box, befBox.HighestModSeq, stats)
|
||||
done, err := w.imapIncremental(ctx, box, befBox, condStore, stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fullyProcessed = done
|
||||
} else if befBox.HighestModSeq != box.HighestModSeq {
|
||||
} else if changed {
|
||||
// The pass was aborted before this folder; hold its cursor too.
|
||||
fullyProcessed = false
|
||||
}
|
||||
|
||||
if befBox.HighestModSeq != box.HighestModSeq || befBox.Name != box.Name || !slices.Equal(befBox.Attrs, box.Attrs) {
|
||||
// The stored mod-sequence only moves once every change up to it
|
||||
// was stored; a deferred message keeps the folder re-asked.
|
||||
if changed || befBox.Name != box.Name || !slices.Equal(befBox.Attrs, box.Attrs) {
|
||||
// The stored cursor only moves once every change up to it was
|
||||
// stored; a deferred message keeps the folder re-asked.
|
||||
next := *box
|
||||
if !fullyProcessed {
|
||||
next.HighestModSeq = befBox.HighestModSeq
|
||||
next.UIDNext = befBox.UIDNext
|
||||
}
|
||||
if err := w.mboxEvent(&next); err != nil {
|
||||
return nil
|
||||
@@ -85,11 +93,26 @@ func (w *WMail) Sync(ctx context.Context) *errx.MailError {
|
||||
for _, ibox := range w.SmtpImapData.Mailboxes {
|
||||
if ibox.UIDValidity == box.UIDValidity {
|
||||
ibox.HighestModSeq = next.HighestModSeq
|
||||
ibox.UIDNext = next.UIDNext
|
||||
ibox.Name = next.Name
|
||||
ibox.Attrs = next.Attrs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without CONDSTORE a message marked read elsewhere moves no cursor,
|
||||
// so read state is mirrored by a periodic scan instead. It runs after
|
||||
// the arrivals above so a message stored this pass is already known.
|
||||
if !condStore && !stats.aborted {
|
||||
w.SmtpImapData.mailbox = box.UIDValidity
|
||||
w.SmtpImapData.folder = imapCanonicalFolder(box)
|
||||
if _, err := w.SmtpImapData.ImapClient.SelectForSync(box.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.imapScanFlags(ctx, box, stats); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect deletions first to avoid modifying the slice during iteration
|
||||
@@ -113,6 +136,9 @@ outer:
|
||||
}
|
||||
|
||||
if len(deleted) > 0 {
|
||||
for _, uidv := range deleted {
|
||||
delete(w.flagScan, uidv)
|
||||
}
|
||||
filtered := w.SmtpImapData.Mailboxes[:0]
|
||||
for _, b := range w.SmtpImapData.Mailboxes {
|
||||
if !slices.Contains(deleted, b.UIDValidity) {
|
||||
@@ -132,11 +158,22 @@ outer:
|
||||
return nil
|
||||
}
|
||||
|
||||
// imapIncremental stores what changed in one folder since modSeq. Known
|
||||
// messages relay their flags unbudgeted; new ones are admitted newest first.
|
||||
// It reports whether every change was stored, which is what lets the folder's
|
||||
// mod-sequence advance.
|
||||
func (w *WMail) imapIncremental(ctx context.Context, box *models.Mailbox, modSeq uint64, stats *tickStats) (bool, *errx.MailError) {
|
||||
// imapFolderChanged reports whether a folder has anything new since the
|
||||
// cursor we hold for it. With CONDSTORE the mod-sequence answers for new mail
|
||||
// AND flag changes; without it only arrivals are visible here, and flag
|
||||
// changes are picked up by the periodic scan in imapIncremental.
|
||||
func imapFolderChanged(before, now *models.Mailbox, condStore bool) bool {
|
||||
if condStore {
|
||||
return before.HighestModSeq != now.HighestModSeq
|
||||
}
|
||||
return before.UIDNext != now.UIDNext
|
||||
}
|
||||
|
||||
// imapIncremental stores what changed in one folder since the held cursor.
|
||||
// Known messages relay their flags unbudgeted; new ones are admitted newest
|
||||
// first. It reports whether every change was stored, which is what lets the
|
||||
// folder's cursor advance.
|
||||
func (w *WMail) imapIncremental(ctx context.Context, box, before *models.Mailbox, condStore bool, stats *tickStats) (bool, *errx.MailError) {
|
||||
client := w.SmtpImapData.ImapClient
|
||||
count, err := client.SelectForSync(box.Name)
|
||||
if err != nil {
|
||||
@@ -145,7 +182,12 @@ func (w *WMail) imapIncremental(ctx context.Context, box *models.Mailbox, modSeq
|
||||
if count == 0 {
|
||||
return true, nil
|
||||
}
|
||||
uids, err := client.SearchChangedSince(modSeq)
|
||||
var uids []goimap.UID
|
||||
if condStore {
|
||||
uids, err = client.SearchChangedSince(before.HighestModSeq)
|
||||
} else {
|
||||
uids, err = client.SearchNewSince(before.UIDNext)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -408,99 +450,20 @@ func (w *WMail) imapBackfill(ctx context.Context, folders []models.Mailbox, stat
|
||||
return nil
|
||||
}
|
||||
|
||||
// imapVirtualFolder is a Gmail label view (All Mail, Starred, Important):
|
||||
// every message in it also lives in a real folder under a different UID, so
|
||||
// syncing it would re-file known mail (All Mail reads as archive) and swap
|
||||
// the (mailbox, uid) pair the warmup actions address. Neither lane looks at
|
||||
// these; a message archived out of every real folder stays unsynced, which
|
||||
// is the ceiling of Gmail-over-IMAP — the OAuth Gmail path has no such gap.
|
||||
// imapVirtualFolder, imapBackfillEligible and imapCanonicalFolder classify a
|
||||
// folder. The rules live in the imap client package, next to the LIST that
|
||||
// produces the attributes, so the sync loop and the Sent-folder resolver
|
||||
// cannot drift apart.
|
||||
func imapVirtualFolder(box *models.Mailbox) bool {
|
||||
for _, a := range box.Attrs {
|
||||
switch strings.ToLower(a) {
|
||||
case "\\all", "\\flagged", "\\important":
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Name fallback only inside Gmail's own namespace: a plain IMAP server
|
||||
// can legitimately have a user folder called "Important" or "Starred".
|
||||
name := strings.ToLower(box.Name)
|
||||
if !strings.HasPrefix(name, "[gmail]/") && !strings.HasPrefix(name, "[google mail]/") {
|
||||
return false
|
||||
}
|
||||
switch name[strings.Index(name, "/")+1:] {
|
||||
case "all mail", "starred", "important":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return imap.IsVirtualFolder(*box)
|
||||
}
|
||||
|
||||
// imapBackfillEligible excludes folders whose history is not worth importing:
|
||||
// trash, spam and Gmail's virtual "All Mail" (a duplicate of every other
|
||||
// folder). Live sync still follows them for placement signals and to file new
|
||||
// mail into the Spam and Trash scopes; only the bounded initial import skips
|
||||
// them, because their history would consume the message budget that belongs to
|
||||
// real conversations. Drafts IS imported: it is small and a Drafts scope with
|
||||
// none of the mailbox's existing drafts in it reads as broken.
|
||||
//
|
||||
// Special-use attributes are authoritative, with a name fallback for servers
|
||||
// that do not advertise them.
|
||||
func imapBackfillEligible(box *models.Mailbox) bool {
|
||||
if imapVirtualFolder(box) {
|
||||
return false
|
||||
}
|
||||
for _, a := range box.Attrs {
|
||||
switch strings.ToLower(a) {
|
||||
case "\\noselect", "\\nonexistent", "\\trash", "\\junk", "\\all":
|
||||
return false
|
||||
}
|
||||
}
|
||||
name := strings.ToLower(box.Name)
|
||||
if i := strings.LastIndexAny(name, "/."); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
switch name {
|
||||
case "trash", "bin", "junk", "spam", "deleted items", "deleted messages", "junk e-mail", "junk email", "bulk mail":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return imap.BackfillEligible(*box)
|
||||
}
|
||||
|
||||
// imapCanonicalFolder maps an IMAP folder to the canonical unibox folder.
|
||||
// Special-use attributes are authoritative, with a name fallback for servers
|
||||
// that do not advertise them; unrecognized user folders file as inbox so
|
||||
// their mail stays visible.
|
||||
func imapCanonicalFolder(box *models.Mailbox) string {
|
||||
for _, a := range box.Attrs {
|
||||
switch strings.ToLower(a) {
|
||||
case "\\sent":
|
||||
return models.FolderSent
|
||||
case "\\drafts":
|
||||
return models.FolderDrafts
|
||||
case "\\junk":
|
||||
return models.FolderSpam
|
||||
case "\\trash":
|
||||
return models.FolderTrash
|
||||
case "\\archive", "\\all":
|
||||
return models.FolderArchive
|
||||
}
|
||||
}
|
||||
name := strings.ToLower(box.Name)
|
||||
if i := strings.LastIndexAny(name, "/."); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
switch name {
|
||||
case "sent", "sent mail", "sent items", "sent messages":
|
||||
return models.FolderSent
|
||||
case "drafts", "draft":
|
||||
return models.FolderDrafts
|
||||
case "junk", "spam", "junk e-mail", "junk email", "bulk mail":
|
||||
return models.FolderSpam
|
||||
case "trash", "bin", "deleted", "deleted items", "deleted messages":
|
||||
return models.FolderTrash
|
||||
case "archive", "archives", "all mail":
|
||||
return models.FolderArchive
|
||||
}
|
||||
return models.FolderInbox
|
||||
return imap.CanonicalFolder(*box)
|
||||
}
|
||||
|
||||
// controlPlaneError handles a failed map lookup, body store or event publish
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package wmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/client/smtpimap/imap"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// reportFolderOverflow tells the user once per worker session that the
|
||||
// mailbox has more folders than the sync follows. Once, not per pass: the
|
||||
// condition is static until someone reorganizes their mail, and a warning a
|
||||
// minute would bury every real error in the drawer.
|
||||
func (w *WMail) reportFolderOverflow() {
|
||||
if w.SmtpImapData.overflowReported {
|
||||
return
|
||||
}
|
||||
client := w.SmtpImapData.ImapClient
|
||||
over, conflicts := client.FolderOverflow(), client.FolderConflicts()
|
||||
if over <= 0 && conflicts <= 0 {
|
||||
return
|
||||
}
|
||||
w.SmtpImapData.overflowReported = true
|
||||
if over > 0 {
|
||||
w.CaptureError(errx.ErrMailFoldersOverflow(over))
|
||||
}
|
||||
if conflicts > 0 {
|
||||
w.CaptureError(errx.ErrMailFoldersConflict(conflicts))
|
||||
}
|
||||
}
|
||||
|
||||
// imapScanFlags mirrors read state and flag changes on a server without
|
||||
// CONDSTORE, which cannot say what changed: it re-reads the flags of the
|
||||
// folder's newest window and relays only the messages whose flags actually
|
||||
// moved since the previous scan.
|
||||
//
|
||||
// It is deliberately periodic (config.ImapFlagScanInterval) rather than every
|
||||
// pass: it is one FETCH per folder over up to config.ImapFlagScanWindow UIDs,
|
||||
// and read state is not worth a round trip a minute per folder. New mail does
|
||||
// not wait for it; that arrives through UIDNEXT on every pass.
|
||||
func (w *WMail) imapScanFlags(ctx context.Context, box *models.Mailbox, stats *tickStats) *errx.MailError {
|
||||
if w.flagScan == nil {
|
||||
w.flagScan = map[uint32]*folderFlagScan{}
|
||||
}
|
||||
scan := w.flagScan[box.UIDValidity]
|
||||
now := time.Now()
|
||||
if scan != nil && now.Sub(scan.at) < config.ImapFlagScanInterval {
|
||||
return nil
|
||||
}
|
||||
|
||||
from := uint32(1)
|
||||
if box.UIDNext > config.ImapFlagScanWindow {
|
||||
from = box.UIDNext - config.ImapFlagScanWindow
|
||||
}
|
||||
flags, err := w.SmtpImapData.ImapClient.FetchFlags(ctx, from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The first scan of a folder only records the baseline: without a
|
||||
// previous scan every message would read as changed and the whole window
|
||||
// would be relayed for nothing.
|
||||
if scan != nil {
|
||||
for uid, state := range flags {
|
||||
before, ok := scan.flags[uid]
|
||||
// Not in the previous scan means it arrived since; the UIDNEXT
|
||||
// path owns it and will store it with its flags.
|
||||
if !ok || sameFlags(before.Flags, state.Flags) {
|
||||
continue
|
||||
}
|
||||
if err := w.relayFlags(ctx, box, uid, state, stats); err != nil {
|
||||
return err
|
||||
}
|
||||
if stats.aborted {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
w.flagScan[box.UIDValidity] = &folderFlagScan{at: now, flags: flags}
|
||||
return nil
|
||||
}
|
||||
|
||||
// relayFlags sends an UPDATE_EMAIL for one message whose flags moved. A
|
||||
// message the platform does not know is skipped: it is not ours to update,
|
||||
// and the message paths admit it under a budget instead.
|
||||
func (w *WMail) relayFlags(ctx context.Context, box *models.Mailbox, uid uint32, state imap.FlagState, stats *tickStats) *errx.MailError {
|
||||
if state.MessageID == "" {
|
||||
return nil
|
||||
}
|
||||
internal, err := w.EmailMessageMapRepository.Get(ctx, w.UserID, w.ID, state.MessageID)
|
||||
if err != nil {
|
||||
return w.controlPlaneError(err, stats)
|
||||
}
|
||||
if internal == nil {
|
||||
return nil
|
||||
}
|
||||
internalID, perr := uuid.Parse(internal.ID)
|
||||
if perr != nil {
|
||||
return nil
|
||||
}
|
||||
if err := w.onEvent(models.JobEventTypeEmailUpdate, &models.JobEventEmailUpdate{
|
||||
UserID: w.UserID,
|
||||
EmailID: w.ID,
|
||||
ID: internalID,
|
||||
UID: uid,
|
||||
Mailbox: box.UIDValidity,
|
||||
Folder: imapCanonicalFolder(box),
|
||||
Flags: state.Flags,
|
||||
}); err != nil {
|
||||
return w.controlPlaneError(err, stats)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// folderFlagScan is the previous flag snapshot of one folder, held in worker
|
||||
// memory only: a replaced worker re-baselines on its first scan, which costs
|
||||
// one FETCH and no wrong updates.
|
||||
type folderFlagScan struct {
|
||||
at time.Time
|
||||
flags map[uint32]imap.FlagState
|
||||
}
|
||||
|
||||
func sameFlags(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
// Flag sets are tiny (under ten) and usually identical, so the quadratic
|
||||
// compare beats allocating a set per message per scan.
|
||||
for _, x := range a {
|
||||
found := false
|
||||
for _, y := range b {
|
||||
if x == y {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// fakeImapConn is the sync pass's view of a server. Only the methods a pass
|
||||
@@ -19,14 +20,49 @@ import (
|
||||
// rather than silently pass.
|
||||
type fakeImapConn struct {
|
||||
ImapConn
|
||||
folders []models.Mailbox
|
||||
changed []goimap.UID
|
||||
fetches int
|
||||
released int
|
||||
folders []models.Mailbox
|
||||
changed []goimap.UID
|
||||
fetches int
|
||||
released int
|
||||
overflow int
|
||||
conflicts int
|
||||
// noCondStore drives the UIDNEXT path instead of the mod-sequence one.
|
||||
noCondStore bool
|
||||
flags map[uint32]imap.FlagState
|
||||
flagScans int
|
||||
}
|
||||
|
||||
func (c *fakeImapConn) Folders() ([]models.Mailbox, *errx.MailError) { return c.folders, nil }
|
||||
|
||||
func (c *fakeImapConn) FolderOverflow() int { return c.overflow }
|
||||
func (c *fakeImapConn) FolderConflicts() int { return c.conflicts }
|
||||
|
||||
// condStore defaults to true: most of these tests exercise the mod-sequence
|
||||
// path, and the UIDNEXT path has its own tests.
|
||||
func (c *fakeImapConn) HasCondStore() bool { return !c.noCondStore }
|
||||
|
||||
func (c *fakeImapConn) SearchNewSince(uidNext uint32) ([]goimap.UID, *errx.MailError) {
|
||||
var out []goimap.UID
|
||||
for _, uid := range c.changed {
|
||||
if uint32(uid) >= uidNext {
|
||||
out = append(out, uid)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *fakeImapConn) FetchFlags(context.Context, uint32) (map[uint32]imap.FlagState, *errx.MailError) {
|
||||
c.flagScans++
|
||||
// A fresh map per call, like the real client: the scan keeps the result
|
||||
// as its baseline, so handing back the same map would compare it to
|
||||
// itself and never see a change.
|
||||
out := make(map[uint32]imap.FlagState, len(c.flags))
|
||||
for uid, st := range c.flags {
|
||||
out[uid] = st
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *fakeImapConn) ReleaseMailbox() { c.released++ }
|
||||
|
||||
func (c *fakeImapConn) SelectForSync(string) (uint32, *errx.MailError) {
|
||||
@@ -249,6 +285,9 @@ type backfillImapConn struct {
|
||||
}
|
||||
|
||||
func (c *backfillImapConn) Folders() ([]models.Mailbox, *errx.MailError) { return c.folders, nil }
|
||||
func (c *backfillImapConn) FolderOverflow() int { return 0 }
|
||||
func (c *backfillImapConn) FolderConflicts() int { return 0 }
|
||||
func (c *backfillImapConn) HasCondStore() bool { return true }
|
||||
func (c *backfillImapConn) ReleaseMailbox() {}
|
||||
|
||||
func (c *backfillImapConn) SelectForSync(name string) (uint32, *errx.MailError) {
|
||||
@@ -343,3 +382,220 @@ func TestImapBackfillRetriesAFolderAfterATransientFailure(t *testing.T) {
|
||||
t.Error("no history was imported at all")
|
||||
}
|
||||
}
|
||||
|
||||
// On a server without CONDSTORE the pass follows UIDNEXT instead of the
|
||||
// mod-sequence. Refusing those servers is what left Outlook.com, Microsoft
|
||||
// 365 over IMAP and Yahoo mailboxes unable to sync at all.
|
||||
func TestImapSyncFollowsUIDNextWithoutCondStore(t *testing.T) {
|
||||
conn := &fakeImapConn{
|
||||
noCondStore: true,
|
||||
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, UIDNext: 104}},
|
||||
changed: []goimap.UID{101, 102, 103},
|
||||
}
|
||||
w, events := newIMAPTestMail(conn, &fixedBudget{allow: 10},
|
||||
&models.Mailbox{Name: "INBOX", UIDValidity: 7, UIDNext: 101})
|
||||
|
||||
if err := w.Sync(t.Context()); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if conn.fetches != 1 {
|
||||
t.Errorf("fetched %d batches, want 1: the new mail above the cursor", conn.fetches)
|
||||
}
|
||||
if got := w.SmtpImapData.Mailboxes[0].UIDNext; got != 104 {
|
||||
t.Errorf("UIDNEXT cursor = %d, want 104 once everything was stored", got)
|
||||
}
|
||||
if !hasEvent(*events, models.JobEventTypeNewEmail) {
|
||||
t.Error("no mail was stored on a server without CONDSTORE")
|
||||
}
|
||||
}
|
||||
|
||||
// The cursor is held when the budget defers part of the batch, exactly as the
|
||||
// mod-sequence is: the held mail is re-offered next pass rather than skipped.
|
||||
func TestImapSyncHoldsUIDNextWhenDeferred(t *testing.T) {
|
||||
conn := &fakeImapConn{
|
||||
noCondStore: true,
|
||||
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, UIDNext: 500}},
|
||||
changed: uidRange(3 * config.ImapFetchBatchSize),
|
||||
}
|
||||
w, _ := newIMAPTestMail(conn, &fixedBudget{allow: 0},
|
||||
&models.Mailbox{Name: "INBOX", UIDValidity: 7, UIDNext: 1})
|
||||
|
||||
if err := w.Sync(t.Context()); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if got := w.SmtpImapData.Mailboxes[0].UIDNext; got != 1 {
|
||||
t.Errorf("UIDNEXT advanced to %d with mail still waiting on the server", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A quiet folder must cost nothing: with the cursor level there is no search
|
||||
// and no fetch, which is what keeps a per-minute pass cheap on a big account.
|
||||
func TestImapSyncSkipsAQuietFolderWithoutCondStore(t *testing.T) {
|
||||
conn := &fakeImapConn{
|
||||
noCondStore: true,
|
||||
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, UIDNext: 101}},
|
||||
}
|
||||
w, _ := newIMAPTestMail(conn, &fixedBudget{allow: 10},
|
||||
&models.Mailbox{Name: "INBOX", UIDValidity: 7, UIDNext: 101})
|
||||
|
||||
if err := w.Sync(t.Context()); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if conn.fetches != 0 {
|
||||
t.Errorf("fetched %d batches from a folder with nothing new", conn.fetches)
|
||||
}
|
||||
}
|
||||
|
||||
// Read state is mirrored by the periodic scan on a server that cannot say what
|
||||
// changed. The first scan only baselines: relaying it would send an update for
|
||||
// every message in the window for nothing.
|
||||
func TestImapFlagScanBaselinesThenRelaysChanges(t *testing.T) {
|
||||
conn := &fakeImapConn{
|
||||
noCondStore: true,
|
||||
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, UIDNext: 101}},
|
||||
flags: map[uint32]imap.FlagState{
|
||||
1: {MessageID: "<known@test>", Flags: []string{}},
|
||||
},
|
||||
}
|
||||
w, events := newIMAPTestMail(conn, &fixedBudget{allow: 10},
|
||||
&models.Mailbox{Name: "INBOX", UIDValidity: 7, UIDNext: 101})
|
||||
// The platform already has this message; only a known message can have
|
||||
// its flags mirrored.
|
||||
w.EmailMessageMapRepository = knownMessageMap{id: uuid.New().String()}
|
||||
|
||||
if err := w.Sync(t.Context()); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if conn.flagScans != 1 {
|
||||
t.Fatalf("ran %d flag scans, want 1", conn.flagScans)
|
||||
}
|
||||
if hasEvent(*events, models.JobEventTypeEmailUpdate) {
|
||||
t.Fatal("the first scan relayed updates; it has nothing to compare against yet")
|
||||
}
|
||||
|
||||
// The message is marked read in the customer's own mail client.
|
||||
conn.flags[1] = imap.FlagState{MessageID: "<known@test>", Flags: []string{"\\Seen"}}
|
||||
w.flagScan[7].at = time.Now().Add(-2 * config.ImapFlagScanInterval)
|
||||
if err := w.Sync(t.Context()); err != nil {
|
||||
t.Fatalf("second Sync: %v", err)
|
||||
}
|
||||
if !hasEvent(*events, models.JobEventTypeEmailUpdate) {
|
||||
t.Error("a message marked read elsewhere was never mirrored")
|
||||
}
|
||||
}
|
||||
|
||||
// The scan is periodic, not per pass: it is a FETCH per folder and read state
|
||||
// is not worth one every minute on every folder.
|
||||
func TestImapFlagScanIsPeriodic(t *testing.T) {
|
||||
conn := &fakeImapConn{
|
||||
noCondStore: true,
|
||||
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, UIDNext: 101}},
|
||||
flags: map[uint32]imap.FlagState{},
|
||||
}
|
||||
w, _ := newIMAPTestMail(conn, &fixedBudget{allow: 10},
|
||||
&models.Mailbox{Name: "INBOX", UIDValidity: 7, UIDNext: 101})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := w.Sync(t.Context()); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
}
|
||||
if conn.flagScans != 1 {
|
||||
t.Errorf("ran %d flag scans over three passes, want 1", conn.flagScans)
|
||||
}
|
||||
}
|
||||
|
||||
// A CONDSTORE server must not pay for the scan: its mod-sequence already
|
||||
// reports flag changes.
|
||||
func TestImapFlagScanIsSkippedWithCondStore(t *testing.T) {
|
||||
conn := &fakeImapConn{
|
||||
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, HighestModSeq: 100}},
|
||||
flags: map[uint32]imap.FlagState{},
|
||||
}
|
||||
w, _ := newIMAPTestMail(conn, &fixedBudget{allow: 10},
|
||||
&models.Mailbox{Name: "INBOX", UIDValidity: 7, HighestModSeq: 100})
|
||||
|
||||
if err := w.Sync(t.Context()); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
if conn.flagScans != 0 {
|
||||
t.Errorf("ran %d flag scans on a CONDSTORE server", conn.flagScans)
|
||||
}
|
||||
}
|
||||
|
||||
// The overflow warning is reported once, not once a minute: the condition is
|
||||
// static until someone reorganizes their mail.
|
||||
func TestFolderOverflowIsReportedOnce(t *testing.T) {
|
||||
conn := &fakeImapConn{
|
||||
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, HighestModSeq: 100}},
|
||||
overflow: 12,
|
||||
}
|
||||
w, events := newIMAPTestMail(conn, &fixedBudget{allow: 10},
|
||||
&models.Mailbox{Name: "INBOX", UIDValidity: 7, HighestModSeq: 100})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := w.Sync(t.Context()); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
}
|
||||
warnings := 0
|
||||
for _, e := range *events {
|
||||
if e.eventType == models.JobEventTypeEmailServerError {
|
||||
warnings++
|
||||
}
|
||||
}
|
||||
if warnings != 1 {
|
||||
t.Errorf("relayed %d folder-overflow warnings over three passes, want 1", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// The two reasons a folder goes unsynced need their own codes, because the way
|
||||
// out differs: get under the cap, versus rename the folder the server gave a
|
||||
// duplicate id. A warning that never reaches the user is the same as no
|
||||
// warning, so this checks the event as well as the code.
|
||||
func TestFolderProblemsReachTheUserWithTheirOwnCodes(t *testing.T) {
|
||||
conn := &fakeImapConn{
|
||||
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, HighestModSeq: 100}},
|
||||
overflow: 3,
|
||||
conflicts: 2,
|
||||
}
|
||||
w, events := newIMAPTestMail(conn, &fixedBudget{allow: 10},
|
||||
&models.Mailbox{Name: "INBOX", UIDValidity: 7, HighestModSeq: 100})
|
||||
|
||||
if err := w.Sync(t.Context()); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
|
||||
codes := map[string]bool{}
|
||||
for _, e := range *events {
|
||||
if e.eventType != models.JobEventTypeEmailServerError {
|
||||
continue
|
||||
}
|
||||
ev, ok := e.body.(models.EmailErrorEvent)
|
||||
if !ok {
|
||||
t.Fatalf("server error carried %T, want an EmailErrorEvent", e.body)
|
||||
}
|
||||
codes[ev.ErrorCode] = true
|
||||
if ev.ErrorType != string(errx.MailErrorWarning) {
|
||||
t.Errorf("%s was relayed as %q; neither reason deactivates a mailbox", ev.ErrorCode, ev.ErrorType)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{string(errx.MailErrorCodeFolderLimit), string(errx.MailErrorCodeFolderConflict)} {
|
||||
if !codes[want] {
|
||||
t.Errorf("%s never reached the user; the warning would go nowhere", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// knownMessageMap answers every lookup with the same stored message, which is
|
||||
// what lets a flag-scan test exercise the relay rather than the "not ours"
|
||||
// early return.
|
||||
type knownMessageMap struct{ id string }
|
||||
|
||||
func (knownMessageMap) Add(context.Context, repository.EmailMessageData) error { return nil }
|
||||
func (m knownMessageMap) Get(_ context.Context, _, _ uuid.UUID, messageID string) (*repository.EmailMessageData, error) {
|
||||
return &repository.EmailMessageData{ID: m.id, MessageID: messageID}, nil
|
||||
}
|
||||
func (knownMessageMap) Del(context.Context, uuid.UUID, uuid.UUID, string, uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ type SmtpImapData struct {
|
||||
// folder is the canonical folder of the mailbox currently being walked,
|
||||
// set alongside mailbox and stamped on every stored/updated message.
|
||||
folder string
|
||||
// overflowReported keeps the "more folders than we follow" warning to
|
||||
// one per worker session; the condition is static until the user
|
||||
// reorganizes their mail.
|
||||
overflowReported bool
|
||||
}
|
||||
|
||||
type WMail struct {
|
||||
@@ -86,6 +90,12 @@ type WMail struct {
|
||||
laneCache laneCache
|
||||
googleTick *tickStats
|
||||
graphTick *tickStats
|
||||
// flagScan is the previous flag snapshot per folder, used only on IMAP
|
||||
// servers without CONDSTORE, which cannot say what changed.
|
||||
flagScan map[uint32]*folderFlagScan
|
||||
// transportFailures counts consecutive passes that could not reach the
|
||||
// mail server, which paces the retry and keeps one outage to one warning.
|
||||
transportFailures int
|
||||
|
||||
Ctx context.Context
|
||||
Cancel context.CancelFunc
|
||||
|
||||
@@ -46,6 +46,7 @@ func (c *Client) AppendToSent(ctx context.Context, raw []byte, sentAt time.Time)
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
cmd := c.client.Append(mailbox, int64(len(raw)), &imap.AppendOptions{
|
||||
// The sender has, by definition, read what they just sent.
|
||||
Flags: []imap.Flag{imap.FlagSeen},
|
||||
@@ -77,6 +78,7 @@ func (c *Client) sentMailbox() (string, error) {
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
|
||||
// RETURN (SPECIAL-USE) is only legal when the server advertises it; without
|
||||
// the capability the attributes may still arrive on an ordinary LIST.
|
||||
@@ -96,12 +98,11 @@ func (c *Client) sentMailbox() (string, error) {
|
||||
if byName != "" {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range ImapSent {
|
||||
// Match the leaf too: plenty of servers namespace folders as
|
||||
// "INBOX.Sent" or "INBOX/Sent".
|
||||
if strings.EqualFold(f.Mailbox, candidate) || strings.EqualFold(leaf(f.Mailbox), candidate) {
|
||||
byName = f.Mailbox
|
||||
}
|
||||
// Match the leaf: plenty of servers namespace folders as
|
||||
// "INBOX.Sent" or "INBOX/Sent", and the name is localized wherever
|
||||
// the server does not advertise \Sent.
|
||||
if matchesFolderName(strings.ToLower(leafWithDelim(f.Mailbox, delimString(f.Delim))), ImapSent) {
|
||||
byName = f.Mailbox
|
||||
}
|
||||
}
|
||||
if err := list.Close(); err != nil {
|
||||
@@ -121,11 +122,26 @@ func (c *Client) sentMailbox() (string, error) {
|
||||
return c.sentMailboxName, nil
|
||||
}
|
||||
|
||||
// leaf returns the last path component of a mailbox name under either of the
|
||||
// two hierarchy delimiters servers use in practice.
|
||||
// leaf returns the last path component of a mailbox name. LIST reports the
|
||||
// server's real delimiter per folder (see leafWithDelim); this guesses when
|
||||
// the caller has only a name, which covers rows stored before the delimiter
|
||||
// was carried and the handful of servers that report none.
|
||||
func leaf(name string) string {
|
||||
if i := strings.LastIndexAny(name, "./"); i >= 0 && i+1 < len(name) {
|
||||
return name[i+1:]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// leafWithDelim is leaf against the delimiter the server itself reported, so
|
||||
// a folder whose name legitimately contains a dot ("Q1.Reports" under a "/"
|
||||
// server) is not cut in the middle.
|
||||
func leafWithDelim(name, delim string) string {
|
||||
if delim == "" {
|
||||
return leaf(name)
|
||||
}
|
||||
if i := strings.LastIndex(name, delim); i >= 0 && i+len(delim) < len(name) {
|
||||
return name[i+len(delim):]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package imap
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLeaf(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
@@ -20,14 +23,11 @@ func TestLeaf(t *testing.T) {
|
||||
func TestImapSentCoversCommonNames(t *testing.T) {
|
||||
// The name list is the fallback when a server does not advertise the
|
||||
// RFC 6154 \Sent attribute; these are what the common servers call it.
|
||||
for _, name := range []string{"Sent", "Sent Items", "Sent Mail"} {
|
||||
found := false
|
||||
for _, candidate := range ImapSent {
|
||||
if candidate == name {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// Localized names matter as much as the English ones: a server that
|
||||
// advertises no \Sent attribute reports the folder in its owner's
|
||||
// language, and an unmatched Sent folder means no sent copies at all.
|
||||
for _, name := range []string{"Sent", "Sent Items", "Sent Mail", "Gesendete Elemente", "Éléments envoyés", "Enviados", "Elküldött elemek"} {
|
||||
if !matchesFolderName(strings.ToLower(name), ImapSent) {
|
||||
t.Fatalf("ImapSent does not cover %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,47 @@ type Client struct {
|
||||
// under a command, and two paths that both see the drop dial once.
|
||||
// Lock order: mu before lifecycle, and never nest a read lock.
|
||||
lifecycle sync.RWMutex
|
||||
|
||||
// conn is the transport under client, nil until the first Connect. It
|
||||
// is swapped under the lifecycle write lock and read under the read lock
|
||||
// like client itself.
|
||||
conn *idleConn
|
||||
|
||||
// IdleTimeout bounds how long a command waits for the server to say
|
||||
// anything before the session is declared dead. Zero means
|
||||
// config.ImapCommandIdleTimeout.
|
||||
IdleTimeout time.Duration
|
||||
|
||||
// plaintext dials without TLS at all. Only the tests set it, to talk to
|
||||
// the in-process server; no product path reaches it, because an IMAP
|
||||
// session in the clear would put the mailbox password on the wire.
|
||||
plaintext bool
|
||||
|
||||
// condStore is whether the current session advertised CONDSTORE after
|
||||
// authentication, which decides between the mod-sequence and the UIDNEXT
|
||||
// incremental sync.
|
||||
condStore atomic.Bool
|
||||
|
||||
// folderOverflow is what the last Folders call had to leave out for the
|
||||
// cap, folderConflicts what it left out for a duplicate UIDVALIDITY.
|
||||
folderOverflow atomic.Int32
|
||||
folderConflicts atomic.Int32
|
||||
}
|
||||
|
||||
// begin starts the idle clock for one command; call the result on exit.
|
||||
// Callers hold the lifecycle read lock, so conn cannot change underneath.
|
||||
func (c *Client) begin() func() {
|
||||
if c.conn == nil {
|
||||
return func() {}
|
||||
}
|
||||
return c.conn.arm()
|
||||
}
|
||||
|
||||
// HasCondStore reports whether the session supports CONDSTORE, the
|
||||
// mod-sequence path of the incremental sync. Without it the sync loop keys
|
||||
// on UIDNEXT and mirrors flags with a periodic scan.
|
||||
func (c *Client) HasCondStore() bool {
|
||||
return c.condStore.Load()
|
||||
}
|
||||
|
||||
// ensureConnected re-dials after the server has dropped the session. go-imap
|
||||
@@ -113,32 +154,50 @@ func (c *Client) connectLocked() *errx.MailError {
|
||||
|
||||
tlsConf := &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: netbind.InsecureTLS(),
|
||||
InsecureSkipVerify: netbind.InsecureTLS(), //nolint:gosec // MAIL_TLS_INSECURE, local dev only
|
||||
}
|
||||
|
||||
// Dial through netbind so both paths honour WORKER_BIND_IP, and wrap the
|
||||
// socket before TLS so the idle clock sits under the encryption.
|
||||
timeout := c.IdleTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = config.ImapCommandIdleTimeout
|
||||
}
|
||||
raw, err := netbind.Dialer(c.BindIP).DialContext(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
conn := &idleConn{Conn: raw, timeout: timeout}
|
||||
|
||||
var client *imapclient.Client
|
||||
if models.ResolveIMAPSecurity(security, port) == models.MailSecurityStartTLS {
|
||||
// Plaintext greeting, upgraded in-band. Dial through netbind so the
|
||||
// STARTTLS path honours WORKER_BIND_IP like the implicit one.
|
||||
conn, err := netbind.Dialer(c.BindIP).DialContext(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
// NewStartTLS closes conn itself when the upgrade fails.
|
||||
switch {
|
||||
case c.plaintext:
|
||||
client = imapclient.New(conn, nil)
|
||||
case models.ResolveIMAPSecurity(security, port) == models.MailSecurityStartTLS:
|
||||
// Plaintext greeting, upgraded in-band. NewStartTLS closes conn
|
||||
// itself when the upgrade fails.
|
||||
client, err = imapclient.NewStartTLS(conn, &imapclient.Options{TLSConfig: tlsConf})
|
||||
if err != nil {
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
} else {
|
||||
conn, err := netbind.TLSDialer(c.BindIP, tlsConf).DialContext(context.Background(), "tcp", addr)
|
||||
default:
|
||||
tconn := tls.Client(conn, tlsConf)
|
||||
hctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
err = tconn.HandshakeContext(hctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = tconn.Close()
|
||||
return errx.ErrMailServerUnreachable
|
||||
}
|
||||
client = imapclient.New(conn, nil)
|
||||
client = imapclient.New(tconn, nil)
|
||||
}
|
||||
|
||||
c.client = client
|
||||
c.conn = conn
|
||||
c.selected.Store(false)
|
||||
c.condStore.Store(false)
|
||||
done := conn.arm()
|
||||
defer done()
|
||||
|
||||
var xerr *errx.MailError
|
||||
|
||||
@@ -155,13 +214,11 @@ func (c *Client) connectLocked() *errx.MailError {
|
||||
return xerr
|
||||
}
|
||||
|
||||
// CONDSTORE backs the ChangedSince incremental sync. Servers (Gmail,
|
||||
// CONDSTORE backs the mod-sequence incremental sync. Servers (Gmail,
|
||||
// Dovecot, ...) typically advertise it only after authentication, so the
|
||||
// check must run post-auth.
|
||||
if !c.client.Caps().Has(imap.CapCondStore) {
|
||||
_ = client.Close()
|
||||
return errx.ErrMailCondStoreNotSupported
|
||||
}
|
||||
// check must run post-auth. Without it (Outlook.com, Microsoft 365 over
|
||||
// IMAP, Yahoo, many hosted servers) the sync loop keys on UIDNEXT instead.
|
||||
c.condStore.Store(c.client.Caps().Has(imap.CapCondStore))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -209,71 +266,10 @@ func (c *Client) oauth2Auth() *errx.MailError {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Folders() ([]models.Mailbox, *errx.MailError) {
|
||||
var resp []models.Mailbox
|
||||
|
||||
if err := c.ensureConnected(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
|
||||
// LIST-STATUS: without requesting these, f.Status is nil for every
|
||||
// folder and the sync loop sees an empty account.
|
||||
//
|
||||
// "*", not "%": "%" stops at the top level, and on Gmail-over-IMAP every
|
||||
// folder but INBOX lives under "[Gmail]/" (Dovecot commonly under
|
||||
// "INBOX."), so Sent, Spam and Trash were never listed and never synced.
|
||||
opts := &imap.ListOptions{
|
||||
ReturnStatus: &imap.StatusOptions{
|
||||
UIDValidity: true,
|
||||
HighestModSeq: true,
|
||||
},
|
||||
}
|
||||
// Gmail attaches \Sent, \Trash, \Junk, \All ... only when asked; on a
|
||||
// plain LIST every folder is just \HasNoChildren and the canonical-folder
|
||||
// mapping is left guessing from names ("Bin" filed as inbox).
|
||||
if c.client.Caps().Has(imap.CapSpecialUse) {
|
||||
opts.ReturnSpecialUse = true
|
||||
}
|
||||
cmd := c.client.List("", "*", opts)
|
||||
|
||||
for f := cmd.Next(); f != nil; f = cmd.Next() {
|
||||
if len(resp) >= config.MaxEmailFolders {
|
||||
// Drain the command first: unread LIST results would sit in the
|
||||
// decoder channel and stall the next command on this session.
|
||||
_ = cmd.Close()
|
||||
return nil, errx.ErrMailFoldersMax
|
||||
}
|
||||
|
||||
var attrs []string = make([]string, len(f.Attrs))
|
||||
|
||||
for i := range f.Attrs {
|
||||
attrs[i] = string(f.Attrs[i])
|
||||
}
|
||||
|
||||
if f.Status == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
resp = append(resp, models.Mailbox{
|
||||
Name: f.Mailbox,
|
||||
Attrs: attrs,
|
||||
UIDValidity: f.Status.UIDValidity,
|
||||
HighestModSeq: f.Status.HighestModSeq,
|
||||
})
|
||||
}
|
||||
|
||||
if err := cmd.Close(); err != nil {
|
||||
return nil, c.handleError(err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) Mailbox(mailbox string, uidvali, opts *imap.SelectOptions) error {
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
if _, err := c.selectMailbox(mailbox, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -299,7 +295,9 @@ func (c *Client) selectMailbox(mailbox string, opts *imap.SelectOptions) (*imap.
|
||||
func (c *Client) SelectForSync(mailbox string) (uint32, *errx.MailError) {
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
data, err := c.selectMailbox(mailbox, &imap.SelectOptions{ReadOnly: true, CondStore: true})
|
||||
defer c.begin()()
|
||||
// (CONDSTORE) on a server without it is a BAD.
|
||||
data, err := c.selectMailbox(mailbox, &imap.SelectOptions{ReadOnly: true, CondStore: c.condStore.Load()})
|
||||
if err != nil {
|
||||
return 0, c.handleError(err)
|
||||
}
|
||||
@@ -322,6 +320,7 @@ func (c *Client) ReleaseMailbox() {
|
||||
if c.client == nil || !c.selected.Load() || !c.client.Caps().Has(imap.CapUnselect) {
|
||||
return
|
||||
}
|
||||
defer c.begin()()
|
||||
if err := c.client.Unselect().Wait(); err == nil {
|
||||
c.selected.Store(false)
|
||||
}
|
||||
@@ -352,9 +351,87 @@ func (c *Client) SearchChangedSince(modSeq uint64) ([]imap.UID, *errx.MailError)
|
||||
return c.uidSearch(&imap.SearchCriteria{ModSeq: &imap.SearchCriteriaModSeq{ModSeq: modSeq + 1}})
|
||||
}
|
||||
|
||||
// SearchNewSince returns the UIDs at or above uidNext: the mail that arrived
|
||||
// since the folder's UIDNEXT was last recorded. It is the incremental set on
|
||||
// a server without CONDSTORE. A "n:*" set with n past the end answers with
|
||||
// the highest UID in the folder (RFC 3501 6.4.8), so the result is filtered.
|
||||
func (c *Client) SearchNewSince(uidNext uint32) ([]imap.UID, *errx.MailError) {
|
||||
if uidNext == 0 {
|
||||
uidNext = 1
|
||||
}
|
||||
var set imap.UIDSet
|
||||
set.AddRange(imap.UID(uidNext), 0)
|
||||
uids, err := c.uidSearch(&imap.SearchCriteria{UID: []imap.UIDSet{set}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := uids[:0]
|
||||
for _, uid := range uids {
|
||||
if uint32(uid) >= uidNext {
|
||||
out = append(out, uid)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FlagState is one message as the flag scan sees it: enough to find the
|
||||
// platform's copy (the RFC Message-ID, which is the map key) and to compare
|
||||
// its flags with the previous scan. Bodies and envelopes are not read.
|
||||
type FlagState struct {
|
||||
MessageID string
|
||||
Flags []string
|
||||
}
|
||||
|
||||
// FetchFlags reads the flags and Message-ID of every message at or above
|
||||
// uidFrom in the selected mailbox, in one round trip with no bodies. It is
|
||||
// how flag and read-state changes are found on a server without CONDSTORE:
|
||||
// the caller diffs it against the previous scan.
|
||||
func (c *Client) FetchFlags(ctx context.Context, uidFrom uint32) (map[uint32]FlagState, *errx.MailError) {
|
||||
if uidFrom == 0 {
|
||||
uidFrom = 1
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
var set imap.UIDSet
|
||||
set.AddRange(imap.UID(uidFrom), 0)
|
||||
cmd := c.client.Fetch(set, &imap.FetchOptions{UID: true, Flags: true, Envelope: true})
|
||||
out := map[uint32]FlagState{}
|
||||
for em := cmd.Next(); em != nil; em = cmd.Next() {
|
||||
var uid uint32
|
||||
var st FlagState
|
||||
for item := em.Next(); item != nil; item = em.Next() {
|
||||
switch item := item.(type) {
|
||||
case imapclient.FetchItemDataUID:
|
||||
uid = uint32(item.UID)
|
||||
case imapclient.FetchItemDataFlags:
|
||||
st.Flags = make([]string, 0, len(item.Flags))
|
||||
for _, f := range item.Flags {
|
||||
st.Flags = append(st.Flags, string(f))
|
||||
}
|
||||
case imapclient.FetchItemDataEnvelope:
|
||||
if item.Envelope != nil {
|
||||
st.MessageID = item.Envelope.MessageID
|
||||
}
|
||||
}
|
||||
}
|
||||
if uid >= uidFrom {
|
||||
out[uid] = st
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := cmd.Close(); err != nil {
|
||||
return nil, c.handleError(err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) uidSearch(criteria *imap.SearchCriteria) ([]imap.UID, *errx.MailError) {
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
data, err := c.client.UIDSearch(criteria, nil).Wait()
|
||||
if err != nil {
|
||||
return nil, c.handleError(err)
|
||||
@@ -375,6 +452,7 @@ func (c *Client) FetchEnvelopes(ctx context.Context, uids []imap.UID) ([]*Fetche
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
cmd := c.client.Fetch(set, &imap.FetchOptions{
|
||||
UID: true,
|
||||
Envelope: true,
|
||||
@@ -459,6 +537,7 @@ func (c *Client) FetchBody(f *Fetched) {
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
f.Email.BodyPlain, f.Email.BodyHTML = fetchTextParts(c.client, f.uid, f.body)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// idleConn is the transport under one session. go-imap puts a deadline on
|
||||
// the bytes of a response once its first byte has arrived, but waits for that
|
||||
// first byte with no deadline at all, so a peer that vanished without a FIN
|
||||
// (a NAT or firewall dropping the mapping) parks the command forever and the
|
||||
// mailbox with it. While a command is in flight this keeps a deadline on the
|
||||
// wait too; between commands it is cleared, because go-imap's reader blocks in
|
||||
// Read the whole time and would otherwise time an idle session out.
|
||||
type idleConn struct {
|
||||
net.Conn
|
||||
timeout time.Duration
|
||||
|
||||
// mu guards inflight together with the deadline calls it drives. A
|
||||
// counter alone races: the sync loop and a warmup action run commands on
|
||||
// one session concurrently, so the last release can clear the deadline
|
||||
// just after another command armed it, leaving that command waiting on a
|
||||
// silent peer forever, which is the failure this type exists to prevent.
|
||||
mu sync.Mutex
|
||||
inflight int
|
||||
}
|
||||
|
||||
// arm starts the clock for one command. The returned func stops it; every
|
||||
// command path calls it on exit.
|
||||
func (c *idleConn) arm() func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.inflight++
|
||||
if c.inflight == 1 {
|
||||
_ = c.Conn.SetReadDeadline(time.Now().Add(c.timeout))
|
||||
}
|
||||
return c.release
|
||||
}
|
||||
|
||||
func (c *idleConn) release() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.inflight--
|
||||
if c.inflight == 0 {
|
||||
_ = c.Conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
}
|
||||
|
||||
// SetReadDeadline is where go-imap manages its own per-response deadline: it
|
||||
// sets one while decoding a response and clears it in between. The cleared
|
||||
// stretch is the gap: that is where the reader waits for the first byte of
|
||||
// the next response, with nothing to fail it if the peer went away without a
|
||||
// FIN. While a command is in flight the clear becomes our timeout instead.
|
||||
//
|
||||
// A deadline go-imap set itself is left alone, never shortened: it already
|
||||
// allows five minutes for a large literal, and cutting that would fail a slow
|
||||
// body fetch that is making progress.
|
||||
func (c *idleConn) SetReadDeadline(t time.Time) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if t.IsZero() && c.inflight > 0 {
|
||||
t = time.Now().Add(c.timeout)
|
||||
}
|
||||
return c.Conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
// Write bounds a send too: a large APPEND to a dead peer blocks once the
|
||||
// socket buffer is full, and nothing else would ever fail it.
|
||||
func (c *idleConn) Write(p []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
armed := c.inflight > 0
|
||||
if armed {
|
||||
_ = c.Conn.SetWriteDeadline(time.Now().Add(c.timeout))
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
n, err := c.Conn.Write(p)
|
||||
|
||||
if armed {
|
||||
c.mu.Lock()
|
||||
// Only clear it if nothing else is mid-command: another writer may
|
||||
// have armed its own deadline while this write was in flight.
|
||||
if c.inflight == 0 {
|
||||
_ = c.Conn.SetWriteDeadline(time.Time{})
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Commands overlap on one session: the sync loop fetches while a warmup
|
||||
// action stores a flag. If the last release could clear the deadline just
|
||||
// after another command armed it, that command would wait on a silent peer
|
||||
// forever, which is the whole failure this type exists to prevent. Run under
|
||||
// -race, this also proves the deadline state is not touched concurrently.
|
||||
func TestIdleConnStaysArmedWhileAnyCommandIsInFlight(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer func() { _ = ln.Close() }()
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
select {}
|
||||
}()
|
||||
|
||||
raw, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
conn := &idleConn{Conn: raw, timeout: 200 * time.Millisecond}
|
||||
|
||||
// One long-running command, with short ones starting and finishing
|
||||
// underneath it the whole time.
|
||||
outer := conn.arm()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
release := conn.arm()
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
release()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// The outer command is still in flight, so its wait must still be bounded.
|
||||
start := time.Now()
|
||||
if _, err := conn.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("a read against a silent peer succeeded")
|
||||
}
|
||||
if waited := time.Since(start); waited > 5*time.Second {
|
||||
t.Fatalf("the read waited %v: a finishing command cleared the deadline out from under one still in flight", waited)
|
||||
}
|
||||
outer()
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// Folders lists the selectable folders of the account with the cursors the
|
||||
// sync loop keys on (UIDVALIDITY, UIDNEXT and, on a CONDSTORE server,
|
||||
// HIGHESTMODSEQ).
|
||||
//
|
||||
// "*", not "%": "%" stops at the top level, and on Gmail-over-IMAP every
|
||||
// folder but INBOX lives under "[Gmail]/" (Dovecot commonly under "INBOX."),
|
||||
// so Sent, Spam and Trash were never listed and never synced.
|
||||
//
|
||||
// The listing is capped at config.MaxEmailFolders, INBOX and the special
|
||||
// folders first so a mailbox with hundreds of user folders still syncs what
|
||||
// matters; FolderOverflow reports how many were left out.
|
||||
func (c *Client) Folders() ([]models.Mailbox, *errx.MailError) {
|
||||
return c.foldersCapped(config.MaxEmailFolders)
|
||||
}
|
||||
|
||||
// foldersCapped is Folders with the cap injectable, so a test can exercise
|
||||
// the overflow without standing up a hundred folders.
|
||||
func (c *Client) foldersCapped(limit int) ([]models.Mailbox, *errx.MailError) {
|
||||
if err := c.ensureConnected(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
|
||||
caps := c.client.Caps()
|
||||
status := &imap.StatusOptions{
|
||||
UIDValidity: true,
|
||||
UIDNext: true,
|
||||
// Asking a server without CONDSTORE for HIGHESTMODSEQ is a BAD.
|
||||
HighestModSeq: caps.Has(imap.CapCondStore),
|
||||
}
|
||||
opts := &imap.ListOptions{}
|
||||
// LIST-STATUS folds the STATUS of every folder into the one round trip;
|
||||
// without it each kept folder is asked separately below.
|
||||
listStatus := caps.Has(imap.CapListStatus)
|
||||
if listStatus {
|
||||
opts.ReturnStatus = status
|
||||
}
|
||||
// Gmail attaches \Sent, \Trash, \Junk, \All ... only when asked; on a
|
||||
// plain LIST every folder is just \HasNoChildren and the canonical-folder
|
||||
// mapping is left guessing from names ("Bin" filed as inbox).
|
||||
if caps.Has(imap.CapSpecialUse) {
|
||||
opts.ReturnSpecialUse = true
|
||||
}
|
||||
|
||||
var all []models.Mailbox
|
||||
statuses := map[string]*imap.StatusData{}
|
||||
cmd := c.client.List("", "*", opts)
|
||||
for f := cmd.Next(); f != nil; f = cmd.Next() {
|
||||
attrs := make([]string, len(f.Attrs))
|
||||
for i := range f.Attrs {
|
||||
attrs[i] = string(f.Attrs[i])
|
||||
}
|
||||
box := models.Mailbox{Name: f.Mailbox, Attrs: attrs, Delim: delimString(f.Delim)}
|
||||
if !selectableFolder(attrs) || IsVirtualFolder(box) {
|
||||
continue
|
||||
}
|
||||
all = append(all, box)
|
||||
if f.Status != nil {
|
||||
statuses[f.Mailbox] = f.Status
|
||||
}
|
||||
}
|
||||
if err := cmd.Close(); err != nil {
|
||||
return nil, c.handleError(err)
|
||||
}
|
||||
|
||||
kept, overflow := rankFolders(all, limit)
|
||||
c.folderOverflow.Store(int32(overflow))
|
||||
|
||||
resp := make([]models.Mailbox, 0, len(kept))
|
||||
for _, box := range kept {
|
||||
st := statuses[box.Name]
|
||||
if st == nil {
|
||||
if listStatus {
|
||||
// The server was asked and said nothing: the folder is not
|
||||
// one it can open for us.
|
||||
continue
|
||||
}
|
||||
data, err := c.client.Status(box.Name, status).Wait()
|
||||
if err != nil {
|
||||
var imapErr *imap.Error
|
||||
if errors.As(err, &imapErr) {
|
||||
// One folder the server will not report on must not
|
||||
// take the rest of the account with it.
|
||||
log.Warn().Err(err).Str("folder", box.Name).Msg("imap: STATUS refused; folder skipped")
|
||||
continue
|
||||
}
|
||||
return nil, c.handleError(err)
|
||||
}
|
||||
st = data
|
||||
}
|
||||
box.UIDValidity = st.UIDValidity
|
||||
box.UIDNext = uint32(st.UIDNext)
|
||||
box.HighestModSeq = st.HighestModSeq
|
||||
resp = append(resp, box)
|
||||
}
|
||||
|
||||
resp, conflicts := dedupeByUIDValidity(resp)
|
||||
c.folderConflicts.Store(int32(conflicts))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// dedupeByUIDValidity keeps one folder per UIDVALIDITY.
|
||||
//
|
||||
// Everything downstream identifies a folder by that number, including the
|
||||
// primary key of the stored folder row, but RFC 3501 only promises UIDs are
|
||||
// stable within one folder: Dovecot and others derive UIDVALIDITY from the
|
||||
// creation time, so a folder tree created in the same second shares one.
|
||||
// Two folders under a single id would advance each other's cursor and delete
|
||||
// each other's row, which loses mail. Dropping the later one leaves it
|
||||
// unsynced (and says so) but leaves every other folder correct. Input is
|
||||
// already ranked, so the inbox and the special folders win any collision.
|
||||
func dedupeByUIDValidity(boxes []models.Mailbox) ([]models.Mailbox, int) {
|
||||
seen := make(map[uint32]string, len(boxes))
|
||||
kept := boxes[:0]
|
||||
conflicts := 0
|
||||
for _, box := range boxes {
|
||||
if other, dup := seen[box.UIDValidity]; dup {
|
||||
log.Warn().
|
||||
Str("folder", box.Name).
|
||||
Str("conflicts_with", other).
|
||||
Uint32("uid_validity", box.UIDValidity).
|
||||
Msg("imap: two folders report the same UIDVALIDITY; the second is not synced")
|
||||
conflicts++
|
||||
continue
|
||||
}
|
||||
seen[box.UIDValidity] = box.Name
|
||||
kept = append(kept, box)
|
||||
}
|
||||
return kept, conflicts
|
||||
}
|
||||
|
||||
// FolderOverflow is how many selectable folders the last Folders call left
|
||||
// out because the account has more than config.MaxEmailFolders.
|
||||
func (c *Client) FolderOverflow() int {
|
||||
return int(c.folderOverflow.Load())
|
||||
}
|
||||
|
||||
// FolderConflicts is how many folders the last Folders call left out because
|
||||
// another folder reported the same UIDVALIDITY.
|
||||
func (c *Client) FolderConflicts() int {
|
||||
return int(c.folderConflicts.Load())
|
||||
}
|
||||
|
||||
// rankFolders orders a listing INBOX first, then the special folders (sent,
|
||||
// drafts, junk, trash, archive), then the rest in the server's order, and
|
||||
// cuts it at limit. Ties keep the server's order, so the result is stable
|
||||
// from one pass to the next.
|
||||
//
|
||||
// The ranking applies whether or not anything is cut, because the sync pass
|
||||
// walks folders in this order and can run out of budget partway: a reply to
|
||||
// the customer's own outreach should land before a mailing list in a user
|
||||
// folder does, whatever order the server happened to list them in.
|
||||
func rankFolders(all []models.Mailbox, limit int) ([]models.Mailbox, int) {
|
||||
rank := func(box *models.Mailbox) int {
|
||||
switch {
|
||||
case strings.EqualFold(box.Name, "INBOX"):
|
||||
return 0
|
||||
case CanonicalFolder(*box) != models.FolderInbox:
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool { return rank(&all[i]) < rank(&all[j]) })
|
||||
if len(all) <= limit {
|
||||
return all, 0
|
||||
}
|
||||
return all[:limit], len(all) - limit
|
||||
}
|
||||
|
||||
// selectableFolder is false for the containers a server lists only to show
|
||||
// hierarchy (\Noselect) and the placeholders of LIST-EXTENDED (\NonExistent).
|
||||
func selectableFolder(attrs []string) bool {
|
||||
for _, a := range attrs {
|
||||
switch strings.ToLower(a) {
|
||||
case "\\noselect", "\\nonexistent":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsVirtualFolder is a Gmail label view (All Mail, Starred, Important):
|
||||
// every message in it also lives in a real folder under a different UID, so
|
||||
// syncing it would re-file known mail (All Mail reads as archive) and swap
|
||||
// the (mailbox, uid) pair the warmup actions address. A message archived out
|
||||
// of every real folder stays unsynced, which is the ceiling of
|
||||
// Gmail-over-IMAP; the OAuth Gmail path has no such gap.
|
||||
func IsVirtualFolder(box models.Mailbox) bool {
|
||||
for _, a := range box.Attrs {
|
||||
switch strings.ToLower(a) {
|
||||
case "\\all", "\\flagged", "\\important":
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Name fallback only inside Gmail's own namespace: a plain IMAP server
|
||||
// can legitimately have a user folder called "Important" or "Starred".
|
||||
// Gmail's delimiter is always "/", so this does not need the server's.
|
||||
lower := strings.ToLower(box.Name)
|
||||
if !strings.HasPrefix(lower, "[gmail]/") && !strings.HasPrefix(lower, "[google mail]/") {
|
||||
return false
|
||||
}
|
||||
switch lower[strings.Index(lower, "/")+1:] {
|
||||
case "all mail", "starred", "important":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BackfillEligible excludes folders whose history is not worth importing:
|
||||
// trash, spam and Gmail's virtual views. Live sync still follows trash and
|
||||
// spam for placement signals and to file new mail into those scopes; only the
|
||||
// bounded initial import skips them, because their history would consume the
|
||||
// message budget that belongs to real conversations. Drafts IS imported: it is
|
||||
// small and a Drafts scope with none of the mailbox's existing drafts in it
|
||||
// reads as broken.
|
||||
func BackfillEligible(box models.Mailbox) bool {
|
||||
if IsVirtualFolder(box) || !selectableFolder(box.Attrs) {
|
||||
return false
|
||||
}
|
||||
switch CanonicalFolder(box) {
|
||||
case models.FolderTrash, models.FolderSpam:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CanonicalFolder maps an IMAP folder to the canonical unibox folder.
|
||||
// Special-use attributes are authoritative, with a name fallback for servers
|
||||
// that do not advertise them; unrecognized user folders file as inbox so
|
||||
// their mail stays visible.
|
||||
func CanonicalFolder(box models.Mailbox) string {
|
||||
for _, a := range box.Attrs {
|
||||
switch strings.ToLower(a) {
|
||||
case "\\sent":
|
||||
return models.FolderSent
|
||||
case "\\drafts":
|
||||
return models.FolderDrafts
|
||||
case "\\junk":
|
||||
return models.FolderSpam
|
||||
case "\\trash":
|
||||
return models.FolderTrash
|
||||
case "\\archive", "\\all":
|
||||
return models.FolderArchive
|
||||
}
|
||||
}
|
||||
// The server reports its own hierarchy delimiter per folder, so a folder
|
||||
// whose name contains a dot on a "/" server is not cut in the middle.
|
||||
leafName := strings.ToLower(leafWithDelim(box.Name, box.Delim))
|
||||
switch {
|
||||
case matchesFolderName(leafName, ImapSent):
|
||||
return models.FolderSent
|
||||
case matchesFolderName(leafName, ImapDrafts):
|
||||
return models.FolderDrafts
|
||||
case matchesFolderName(leafName, ImapSpam):
|
||||
return models.FolderSpam
|
||||
case matchesFolderName(leafName, ImapTrash):
|
||||
return models.FolderTrash
|
||||
case matchesFolderName(leafName, ImapArchive):
|
||||
return models.FolderArchive
|
||||
}
|
||||
return models.FolderInbox
|
||||
}
|
||||
|
||||
// delimString renders the delimiter LIST reported. go-imap carries it as a
|
||||
// rune and a server that has no hierarchy reports NIL, which arrives as 0;
|
||||
// converting that directly would produce a NUL byte and make every name look
|
||||
// like it has no separator.
|
||||
func delimString(delim rune) string {
|
||||
if delim == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(delim)
|
||||
}
|
||||
|
||||
// matchesFolderName compares an already-lowercased leaf against one of the
|
||||
// role lists. Exact match only: "spam reports" is a user folder, not spam.
|
||||
func matchesFolderName(leafName string, names []string) bool {
|
||||
for _, n := range names {
|
||||
if leafName == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// Everything downstream identifies a folder by its UIDVALIDITY, and the
|
||||
// database keys the folder row on it, but RFC 3501 only promises UIDs are
|
||||
// stable within one folder: Dovecot and others derive UIDVALIDITY from the
|
||||
// creation time, so a folder tree created in one second shares one. Two
|
||||
// folders under a single id would advance each other's cursor and delete each
|
||||
// other's row, so only one is followed.
|
||||
func TestRankFoldersPutsInboxAndSpecialFoldersFirst(t *testing.T) {
|
||||
all := []models.Mailbox{
|
||||
{Name: "Projects"},
|
||||
{Name: "Archive"},
|
||||
{Name: "INBOX"},
|
||||
{Name: "Notes"},
|
||||
{Name: "Sent", Attrs: []string{"\\Sent"}},
|
||||
}
|
||||
kept, overflow := rankFolders(all, 3)
|
||||
if overflow != 2 {
|
||||
t.Fatalf("overflow = %d, want 2", overflow)
|
||||
}
|
||||
if kept[0].Name != "INBOX" {
|
||||
t.Fatalf("kept[0] = %q, want INBOX to survive any cap", kept[0].Name)
|
||||
}
|
||||
for _, want := range []string{"Sent", "Archive"} {
|
||||
found := false
|
||||
for _, b := range kept {
|
||||
if b.Name == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%q was cut in favour of a plain user folder", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A listing that fits keeps every folder, and is still ranked: the pass walks
|
||||
// folders in this order and can run out of budget partway, so the inbox and
|
||||
// the special folders should be the ones that got through.
|
||||
func TestRankFoldersKeepsEverythingUnderTheCap(t *testing.T) {
|
||||
all := []models.Mailbox{{Name: "Work"}, {Name: "INBOX"}, {Name: "Sent"}}
|
||||
kept, overflow := rankFolders(all, 10)
|
||||
if overflow != 0 {
|
||||
t.Fatalf("overflow = %d with everything under the cap", overflow)
|
||||
}
|
||||
if len(kept) != 3 {
|
||||
t.Fatalf("kept %d folders, want all 3", len(kept))
|
||||
}
|
||||
if kept[0].Name != "INBOX" || kept[1].Name != "Sent" || kept[2].Name != "Work" {
|
||||
t.Fatalf("order = %+v, want the inbox then the special folder then the user folder", kept)
|
||||
}
|
||||
}
|
||||
|
||||
// A container the server lists only to show hierarchy cannot be SELECTed, so
|
||||
// following it would fail every pass.
|
||||
func TestSelectableFolder(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
attrs []string
|
||||
want bool
|
||||
}{
|
||||
{[]string{"\\HasChildren"}, true},
|
||||
{nil, true},
|
||||
{[]string{"\\Noselect"}, false},
|
||||
{[]string{"\\NonExistent"}, false},
|
||||
{[]string{"\\NoSelect", "\\HasChildren"}, false},
|
||||
} {
|
||||
if got := selectableFolder(tc.attrs); got != tc.want {
|
||||
t.Errorf("selectableFolder(%v) = %v, want %v", tc.attrs, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A server that publishes no special-use attributes reports its folders in
|
||||
// the mailbox owner's language, and an unmatched Sent folder means the
|
||||
// customer's sent mail shows up in their inbox instead.
|
||||
func TestCanonicalFolderLocalizedNames(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"Gesendete Elemente", models.FolderSent},
|
||||
{"Éléments envoyés", models.FolderSent},
|
||||
{"Elementos enviados", models.FolderSent},
|
||||
{"Elküldött elemek", models.FolderSent},
|
||||
{"INBOX.Papierkorb", models.FolderTrash},
|
||||
{"Corbeille", models.FolderTrash},
|
||||
{"Posta indesiderata", models.FolderSpam},
|
||||
{"Skräppost", models.FolderSpam},
|
||||
{"Entwürfe", models.FolderDrafts},
|
||||
{"Archiwum", models.FolderArchive},
|
||||
// A user folder that merely mentions a role stays where it is.
|
||||
{"Spam reports", models.FolderInbox},
|
||||
{"Sent to legal", models.FolderInbox},
|
||||
{"Clients/Acme", models.FolderInbox},
|
||||
} {
|
||||
if got := CanonicalFolder(models.Mailbox{Name: tc.name}); got != tc.want {
|
||||
t.Errorf("CanonicalFolder(%q) = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The spam guard decides whether a warmup action may MOVE a message out of a
|
||||
// folder, so a folder that merely mentions spam must not qualify.
|
||||
func TestIsSpamMailboxName(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{"Spam", true},
|
||||
{"Junk", true},
|
||||
{"[Gmail]/Spam", true},
|
||||
{"INBOX.Junk E-Mail", true},
|
||||
{"Courrier indésirable", true},
|
||||
{"Spam reports", false},
|
||||
{"Junk drawer", false},
|
||||
{"INBOX", false},
|
||||
} {
|
||||
if got := IsSpamMailboxName(tc.name); got != tc.want {
|
||||
t.Errorf("IsSpamMailboxName(%q) = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gmail's label views duplicate every message under another UID; following
|
||||
// one would re-file known mail as archive and swap the (mailbox, uid) pair
|
||||
// the warmup actions address.
|
||||
func TestIsVirtualFolder(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
attrs []string
|
||||
want bool
|
||||
}{
|
||||
{"[Gmail]/All Mail", []string{"\\All"}, true},
|
||||
{"[Gmail]/All Mail", []string{"\\HasNoChildren"}, true},
|
||||
{"[Google Mail]/Important", nil, true},
|
||||
{"[Gmail]/Starred", nil, true},
|
||||
{"[Gmail]/Sent Mail", []string{"\\Sent"}, false},
|
||||
{"[Gmail]/Bin", []string{"\\Trash"}, false},
|
||||
{"Important", nil, false},
|
||||
{"All Mail", nil, false},
|
||||
{"INBOX", nil, false},
|
||||
} {
|
||||
if got := IsVirtualFolder(models.Mailbox{Name: tc.name, Attrs: tc.attrs}); got != tc.want {
|
||||
t.Errorf("IsVirtualFolder(%q, %v) = %v, want %v", tc.name, tc.attrs, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two folders sharing a UIDVALIDITY is a server doing something RFC 3501 does
|
||||
// not forbid but everything downstream assumes away: the folder row is keyed
|
||||
// on it. Following both would advance each other's cursor and delete each
|
||||
// other's row, so one is dropped and reported.
|
||||
func TestDedupeByUIDValidity(t *testing.T) {
|
||||
kept, conflicts := dedupeByUIDValidity([]models.Mailbox{
|
||||
{Name: "INBOX", UIDValidity: 100},
|
||||
{Name: "Sent", UIDValidity: 200},
|
||||
// Created in the same second as Sent on a server that stamps
|
||||
// UIDVALIDITY with the creation time.
|
||||
{Name: "Projects", UIDValidity: 200},
|
||||
{Name: "Notes", UIDValidity: 300},
|
||||
})
|
||||
if conflicts != 1 {
|
||||
t.Fatalf("conflicts = %d, want 1", conflicts)
|
||||
}
|
||||
if len(kept) != 3 {
|
||||
t.Fatalf("kept %d folders, want 3", len(kept))
|
||||
}
|
||||
// The ranked order puts the special folder first, so Sent is the one that
|
||||
// survives and the plain user folder is the one dropped.
|
||||
if kept[1].Name != "Sent" {
|
||||
t.Errorf("kept[1] = %q, want Sent to win the collision", kept[1].Name)
|
||||
}
|
||||
seen := map[uint32]bool{}
|
||||
for _, b := range kept {
|
||||
if seen[b.UIDValidity] {
|
||||
t.Fatalf("UIDVALIDITY %d survived twice", b.UIDValidity)
|
||||
}
|
||||
seen[b.UIDValidity] = true
|
||||
}
|
||||
}
|
||||
|
||||
// The common case must not allocate a conflict or reorder anything.
|
||||
func TestDedupeByUIDValidityLeavesADistinctListingAlone(t *testing.T) {
|
||||
in := []models.Mailbox{{Name: "INBOX", UIDValidity: 1}, {Name: "Sent", UIDValidity: 2}}
|
||||
kept, conflicts := dedupeByUIDValidity(in)
|
||||
if conflicts != 0 || len(kept) != 2 || kept[0].Name != "INBOX" || kept[1].Name != "Sent" {
|
||||
t.Fatalf("a listing with distinct ids was changed: %+v, conflicts %d", kept, conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
// A server picks its own hierarchy delimiter and reports it on every LIST
|
||||
// reply. Splitting on "." and "/" alone leaves a folder under any other
|
||||
// separator unclassified, so the customer's sent mail shows up in the inbox
|
||||
// scope; a server with no hierarchy at all reports NIL, which must not be
|
||||
// read as a NUL separator.
|
||||
func TestCanonicalFolderUsesTheServerDelimiter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
box models.Mailbox
|
||||
want string
|
||||
}{
|
||||
{models.Mailbox{Name: "Parent|Sent Items", Delim: "|"}, models.FolderSent},
|
||||
{models.Mailbox{Name: `Parent\Trash`, Delim: `\`}, models.FolderTrash},
|
||||
{models.Mailbox{Name: "INBOX.Sent", Delim: "."}, models.FolderSent},
|
||||
{models.Mailbox{Name: "INBOX/Sent", Delim: "/"}, models.FolderSent},
|
||||
// No delimiter reported: fall back to the "." and "/" guess.
|
||||
{models.Mailbox{Name: "INBOX.Sent"}, models.FolderSent},
|
||||
{models.Mailbox{Name: "Sent"}, models.FolderSent},
|
||||
// A dot in the name is part of the name on a "/" server.
|
||||
{models.Mailbox{Name: "Q1.Reports", Delim: "/"}, models.FolderInbox},
|
||||
} {
|
||||
if got := CanonicalFolder(tc.box); got != tc.want {
|
||||
t.Errorf("CanonicalFolder(%q delim %q) = %q, want %q", tc.box.Name, tc.box.Delim, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The delimiter LIST reports is a rune, and a server with no hierarchy sends
|
||||
// NIL, which arrives as 0. Converting that straight to a string yields a NUL
|
||||
// byte, which matches nothing and hides the fallback.
|
||||
func TestDelimString(t *testing.T) {
|
||||
if got := delimString(0); got != "" {
|
||||
t.Errorf("delimString(NIL) = %q, want an empty string", got)
|
||||
}
|
||||
if got := delimString('/'); got != "/" {
|
||||
t.Errorf("delimString('/') = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,66 @@
|
||||
package imap
|
||||
|
||||
// Folder names by role, lowercase, leaf only (the hierarchy prefix is
|
||||
// stripped before matching). RFC 6154 special-use attributes are always
|
||||
// preferred; these are the fallback for the servers that attach none, where
|
||||
// the name is all there is. Older Exchange, cPanel and plenty of hosted
|
||||
// Dovecots are in that group, and their folders carry the mailbox owner's
|
||||
// language, so a list of English names alone files a customer's sent mail
|
||||
// into their inbox.
|
||||
//
|
||||
// Kept as exported slices because the spam guard and the Sent resolver read
|
||||
// them too. Everything here is compared case-insensitively.
|
||||
var (
|
||||
ImapSpam []string = []string{"Spam", "Junk"}
|
||||
ImapSent []string = []string{"Sent Mail", "Sent", "Sent Items"}
|
||||
ImapDrafts []string = []string{"Draft", "Drafts"}
|
||||
ImapTrash []string = []string{"Trash", "Deleted", "Deleted Items"}
|
||||
// ImapSpam is also the guard on the warmup "remove from spam" action, so
|
||||
// a name that is merely spam-ish (a user folder called "Spam reports")
|
||||
// must not appear here.
|
||||
ImapSpam = []string{
|
||||
"spam", "junk", "junk e-mail", "junk email", "bulk mail",
|
||||
"correo no deseado", "no deseado", // Spanish
|
||||
"courrier indésirable", "indésirables", "pourriel", // French
|
||||
"junk-e-mail", "spamverdacht", // German, Dutch
|
||||
"lixo eletrônico", "lixo electrónico", // Portuguese
|
||||
"posta indesiderata", // Italian
|
||||
"skräppost", "uønsket e-post", "roskaposti", // Swedish, Norwegian, Finnish
|
||||
"spam-mappe", "levélszemét", // Danish-ish, Hungarian
|
||||
"niechciane", "spam-post", // Polish
|
||||
}
|
||||
ImapSent = []string{
|
||||
"sent", "sent mail", "sent items", "sent messages",
|
||||
"enviados", "elementos enviados", "correo enviado", // Spanish
|
||||
"éléments envoyés", "messages envoyés", "envoyés", // French
|
||||
"gesendet", "gesendete elemente", "gesendete objekte", // German
|
||||
"verzonden", "verzonden items", // Dutch
|
||||
"itens enviados", "enviadas", // Portuguese
|
||||
"posta inviata", "inviata", // Italian
|
||||
"skickat", "skickade objekt", "sendt", "lähetetyt", // Nordic
|
||||
"elküldött elemek", "elküldött üzenetek", // Hungarian
|
||||
"elementy wysłane", "wysłane", // Polish
|
||||
"odeslaná pošta", "trimise", // Czech, Romanian
|
||||
}
|
||||
ImapDrafts = []string{
|
||||
"draft", "drafts",
|
||||
"borradores", "brouillons", "entwürfe", "concepten",
|
||||
"rascunhos", "bozze", "utkast", "luonnokset",
|
||||
"piszkozatok", "kopie robocze", "koncepty", "ciorne",
|
||||
}
|
||||
ImapTrash = []string{
|
||||
"trash", "bin", "deleted", "deleted items", "deleted messages",
|
||||
"papelera", "elementos eliminados", // Spanish
|
||||
"corbeille", "éléments supprimés", // French
|
||||
"papierkorb", "gelöschte elemente", "gelöschte objekte", // German
|
||||
"prullenbak", "verwijderde items", // Dutch
|
||||
"lixeira", "itens excluídos", // Portuguese
|
||||
"cestino", "posta eliminata", // Italian
|
||||
"papperskorgen", "slettet post", "roskakori", // Nordic
|
||||
"törölt elemek", "kuka", // Hungarian
|
||||
"kosz", "elementy usunięte", // Polish
|
||||
"koš", "coș de gunoi", // Czech, Romanian
|
||||
}
|
||||
ImapArchive = []string{
|
||||
"archive", "archives", "all mail",
|
||||
"archivo", "archivado", "archives", "archiv",
|
||||
"archief", "arquivo", "archivio", "arkiv",
|
||||
"arkisto", "archívum", "archiwum", "arhiva",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// The in-process server has no CONDSTORE and no LIST-STATUS, which is exactly
|
||||
// the shape this package used to refuse to talk to (Outlook.com, Microsoft
|
||||
// 365 over IMAP, Yahoo, plenty of hosted servers). Every test here runs
|
||||
// against it, so "works on a plain RFC 3501 server" is a thing CI checks
|
||||
// rather than a thing we believe.
|
||||
func testServer(t *testing.T, caps imap.CapSet, folders ...string) *Client {
|
||||
t.Helper()
|
||||
mem := imapmemserver.New()
|
||||
user := imapmemserver.NewUser("warmbly@test", "hunter2")
|
||||
if err := user.Create("INBOX", nil); err != nil {
|
||||
t.Fatalf("create INBOX: %v", err)
|
||||
}
|
||||
for _, f := range folders {
|
||||
if err := user.Create(f, nil); err != nil {
|
||||
t.Fatalf("create %q: %v", f, err)
|
||||
}
|
||||
}
|
||||
mem.AddUser(user)
|
||||
|
||||
if caps == nil {
|
||||
caps = imap.CapSet{imap.CapIMAP4rev1: {}}
|
||||
}
|
||||
srv := imapserver.New(&imapserver.Options{
|
||||
NewSession: func(*imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
return mem.NewSession(), nil, nil
|
||||
},
|
||||
Caps: caps,
|
||||
InsecureAuth: true,
|
||||
})
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
t.Cleanup(func() { _ = srv.Close() })
|
||||
|
||||
host, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||
c := &Client{
|
||||
Email: "warmbly@test",
|
||||
AuthType: models.AuthPlain,
|
||||
Credentials: &models.Service{
|
||||
Username: "warmbly@test",
|
||||
Password: "hunter2",
|
||||
Host: host,
|
||||
Port: atoi(port),
|
||||
},
|
||||
// The in-process server speaks plaintext; no product path does.
|
||||
plaintext: true,
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
return c
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// A server without CONDSTORE must connect. Refusing it is what left
|
||||
// Outlook.com and Yahoo mailboxes unable to sync at all.
|
||||
func TestConnectWithoutCondStore(t *testing.T) {
|
||||
c := testServer(t, nil)
|
||||
if err := c.Connect(); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
if c.HasCondStore() {
|
||||
t.Error("HasCondStore on a server that does not advertise it")
|
||||
}
|
||||
}
|
||||
|
||||
// Without LIST-STATUS the cursors have to come from a STATUS per folder.
|
||||
// Skipping the folders instead (what the old code did) made the account look
|
||||
// empty and retired every saved cursor with nothing logged.
|
||||
func TestFoldersWithoutListStatus(t *testing.T) {
|
||||
c := testServer(t, nil, "Sent", "Archive")
|
||||
if err := c.Connect(); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
boxes, err := c.Folders()
|
||||
if err != nil {
|
||||
t.Fatalf("Folders: %v", err)
|
||||
}
|
||||
if len(boxes) != 3 {
|
||||
t.Fatalf("listed %d folders, want 3: %+v", len(boxes), boxes)
|
||||
}
|
||||
for _, b := range boxes {
|
||||
if b.UIDValidity == 0 {
|
||||
t.Errorf("%q came back with no UIDVALIDITY; its cursor would be meaningless", b.Name)
|
||||
}
|
||||
if b.UIDNext == 0 {
|
||||
t.Errorf("%q came back with no UIDNEXT, which is the incremental cursor here", b.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A nested folder must be listed: "%" stopped at the top level, which is how
|
||||
// Gmail's [Gmail]/Sent Mail and Dovecot's INBOX.Sent went unsynced.
|
||||
func TestFoldersListsNested(t *testing.T) {
|
||||
c := testServer(t, nil, "Clients", "Clients/Acme")
|
||||
if err := c.Connect(); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
boxes, err := c.Folders()
|
||||
if err != nil {
|
||||
t.Fatalf("Folders: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, b := range boxes {
|
||||
names = append(names, b.Name)
|
||||
}
|
||||
if !contains(names, "Clients/Acme") {
|
||||
t.Fatalf("nested folder missing from %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// INBOX and the special folders survive the cap; the overflow is reported
|
||||
// rather than failing the whole mailbox, which is what a user with a lot of
|
||||
// folders used to get (silently).
|
||||
func TestFoldersCapKeepsInboxAndSpecialFolders(t *testing.T) {
|
||||
var many []string
|
||||
for i := 0; i < 30; i++ {
|
||||
many = append(many, "Project"+string(rune('a'+i%26))+string(rune('0'+i/26)))
|
||||
}
|
||||
many = append(many, "Sent", "Trash")
|
||||
c := testServer(t, nil, many...)
|
||||
if err := c.Connect(); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
|
||||
boxes, err := c.foldersCapped(5)
|
||||
if err != nil {
|
||||
t.Fatalf("Folders: %v", err)
|
||||
}
|
||||
if len(boxes) != 5 {
|
||||
t.Fatalf("kept %d folders, want the cap of 5", len(boxes))
|
||||
}
|
||||
var names []string
|
||||
for _, b := range boxes {
|
||||
names = append(names, b.Name)
|
||||
}
|
||||
for _, want := range []string{"INBOX", "Sent", "Trash"} {
|
||||
if !contains(names, want) {
|
||||
t.Errorf("%q was cut; the inbox and special folders must survive the cap. kept: %v", want, names)
|
||||
}
|
||||
}
|
||||
if c.FolderOverflow() != len(many)+1-5 {
|
||||
t.Errorf("FolderOverflow = %d, want %d", c.FolderOverflow(), len(many)+1-5)
|
||||
}
|
||||
}
|
||||
|
||||
// The UIDNEXT search is the incremental set without CONDSTORE: everything at
|
||||
// or above the cursor, and nothing below it.
|
||||
func TestSearchNewSince(t *testing.T) {
|
||||
c := testServer(t, nil)
|
||||
if err := c.Connect(); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
appendMessage(t, c, "INBOX", "<m"+string(rune('1'+i))+"@test>")
|
||||
}
|
||||
if _, err := c.SelectForSync("INBOX"); err != nil {
|
||||
t.Fatalf("SelectForSync: %v", err)
|
||||
}
|
||||
|
||||
all, err := c.SearchNewSince(1)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchNewSince(1): %v", err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("SearchNewSince(1) returned %d UIDs, want 3", len(all))
|
||||
}
|
||||
// A cursor past the end must return nothing. IMAP answers "n:*" with the
|
||||
// last message when n is beyond the end, so an unfiltered result would
|
||||
// re-offer the newest message on every quiet pass forever.
|
||||
none, err := c.SearchNewSince(uint32(all[len(all)-1]) + 1)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchNewSince(past the end): %v", err)
|
||||
}
|
||||
if len(none) != 0 {
|
||||
t.Fatalf("a cursor past the end returned %v, want nothing", none)
|
||||
}
|
||||
}
|
||||
|
||||
// The flag scan is how read state is mirrored without CONDSTORE, so it has to
|
||||
// carry the Message-ID the platform keys on.
|
||||
func TestFetchFlagsCarriesMessageID(t *testing.T) {
|
||||
c := testServer(t, nil)
|
||||
if err := c.Connect(); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
appendMessage(t, c, "INBOX", "<flagme@test>")
|
||||
if _, err := c.SelectForSync("INBOX"); err != nil {
|
||||
t.Fatalf("SelectForSync: %v", err)
|
||||
}
|
||||
got, err := c.FetchFlags(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchFlags: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("FetchFlags returned %d messages, want 1", len(got))
|
||||
}
|
||||
for _, st := range got {
|
||||
if st.MessageID != "flagme@test" && st.MessageID != "<flagme@test>" {
|
||||
t.Errorf("MessageID = %q, want the message's own id", st.MessageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A session the server has closed must be re-dialed rather than failing
|
||||
// forever, and the re-dial has to happen without the caller knowing.
|
||||
func TestEnsureConnectedRedialsAfterDrop(t *testing.T) {
|
||||
c := testServer(t, nil)
|
||||
if err := c.Connect(); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
if _, err := c.Folders(); err != nil {
|
||||
t.Fatalf("first Folders: %v", err)
|
||||
}
|
||||
|
||||
// Drop it the way a server does: close the socket under the client.
|
||||
_ = c.conn.Conn.Close()
|
||||
waitForLogout(t, c)
|
||||
|
||||
if _, err := c.Folders(); err != nil {
|
||||
t.Fatalf("Folders after the session was dropped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A peer that goes away without a FIN must not park a command forever.
|
||||
//
|
||||
// go-imap puts a 30 second deadline on a response it has already started
|
||||
// reading, but between responses it clears the deadline entirely, and that is
|
||||
// where the reader waits for the first byte of the answer to the command we
|
||||
// just sent. A NAT or firewall that drops the mapping leaves the socket open
|
||||
// and silent, so before idleConn that wait never ended: the mailbox stopped
|
||||
// syncing until the worker restarted, the same zombie a dropped session used
|
||||
// to cause.
|
||||
func TestIdleConnBoundsTheWaitBetweenResponses(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer func() { _ = ln.Close() }()
|
||||
// Accept and then say nothing, holding the socket open.
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
select {}
|
||||
}()
|
||||
|
||||
raw, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
conn := &idleConn{Conn: raw, timeout: 300 * time.Millisecond}
|
||||
release := conn.arm()
|
||||
defer release()
|
||||
|
||||
// Exactly what go-imap does around each response: a deadline while it
|
||||
// decodes, cleared when it is done.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
|
||||
start := time.Now()
|
||||
if _, err := conn.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("a read against a silent peer succeeded")
|
||||
}
|
||||
if waited := time.Since(start); waited > 5*time.Second {
|
||||
t.Fatalf("the read waited %v; the cleared deadline left it unbounded", waited)
|
||||
}
|
||||
}
|
||||
|
||||
// Between commands the deadline is released, because go-imap's reader sits in
|
||||
// Read the whole time a session is idle and would otherwise time out a
|
||||
// perfectly healthy connection.
|
||||
func TestIdleConnReleasesTheDeadlineBetweenCommands(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer func() { _ = ln.Close() }()
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
// Quiet for longer than the idle timeout, then speak: a mailbox with
|
||||
// no new mail looks exactly like this.
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
_, _ = conn.Write([]byte("* OK still here\r\n"))
|
||||
time.Sleep(time.Second)
|
||||
}()
|
||||
|
||||
raw, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
conn := &idleConn{Conn: raw, timeout: 100 * time.Millisecond}
|
||||
conn.arm()()
|
||||
|
||||
if _, err := conn.Read(make([]byte, 1)); err != nil {
|
||||
t.Fatalf("an idle session was cut while no command was in flight: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A large literal is allowed the time go-imap gives it: the wrapper fills in
|
||||
// a missing deadline, it never shortens one, so a slow body fetch that is
|
||||
// making progress is not failed at the idle timeout.
|
||||
func TestIdleConnDoesNotShortenAnExistingDeadline(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer func() { _ = ln.Close() }()
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, _ = conn.Write([]byte("x"))
|
||||
time.Sleep(time.Second)
|
||||
}()
|
||||
|
||||
raw, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
conn := &idleConn{Conn: raw, timeout: 50 * time.Millisecond}
|
||||
release := conn.arm()
|
||||
defer release()
|
||||
// go-imap's literal read timeout, far longer than ours.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
if _, err := conn.Read(make([]byte, 1)); err != nil {
|
||||
t.Fatalf("a read go-imap had given 5s was cut at the idle timeout: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func appendMessage(t *testing.T, c *Client, mailbox, messageID string) {
|
||||
t.Helper()
|
||||
raw := "From: someone@test\r\nTo: warmbly@test\r\nSubject: hello\r\nMessage-ID: " + messageID +
|
||||
"\r\nDate: Mon, 2 Jan 2006 15:04:05 -0700\r\n\r\nbody\r\n"
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
cmd := c.client.Append(mailbox, int64(len(raw)), nil)
|
||||
if _, err := cmd.Write([]byte(raw)); err != nil {
|
||||
t.Fatalf("append write: %v", err)
|
||||
}
|
||||
if err := cmd.Close(); err != nil {
|
||||
t.Fatalf("append close: %v", err)
|
||||
}
|
||||
if _, err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForLogout(t *testing.T, c *Client) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c.lifecycle.RLock()
|
||||
state := c.client.State()
|
||||
c.lifecycle.RUnlock()
|
||||
if state == imap.ConnStateLogout {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("the client never noticed the dropped socket")
|
||||
}
|
||||
|
||||
func contains(all []string, want string) bool {
|
||||
for _, s := range all {
|
||||
if strings.EqualFold(s, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func (c *Client) MarkAsRead(ctx context.Context, mailboxName string, uid uint32)
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
if _, err := c.selectMailbox(mailboxName, nil); err != nil {
|
||||
return fmt.Errorf("select %q: %w", mailboxName, err)
|
||||
}
|
||||
@@ -46,6 +47,7 @@ func (c *Client) MarkImportant(ctx context.Context, mailboxName string, uid uint
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
if _, err := c.selectMailbox(mailboxName, nil); err != nil {
|
||||
return fmt.Errorf("select %q: %w", mailboxName, err)
|
||||
}
|
||||
@@ -82,6 +84,7 @@ func (c *Client) MoveToFolder(ctx context.Context, sourceMailbox, dstFolder stri
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
dst := c.qualifyMailboxLocked(dstFolder)
|
||||
if err := c.ensureMailboxExists(dst); err != nil {
|
||||
return err
|
||||
@@ -127,6 +130,7 @@ func (c *Client) moveUID(ctx context.Context, src, dst string, uid uint32) error
|
||||
}
|
||||
c.lifecycle.RLock()
|
||||
defer c.lifecycle.RUnlock()
|
||||
defer c.begin()()
|
||||
return c.moveUIDLocked(src, dst, uid)
|
||||
}
|
||||
|
||||
@@ -181,19 +185,12 @@ func (c *Client) ensureMailboxExists(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSpamMailboxName returns true if the mailbox name looks like Junk/Spam.
|
||||
// Used as a guard so we never accidentally MOVE a non-spam message.
|
||||
// IsSpamMailboxName returns true if the mailbox name is a Junk/Spam folder.
|
||||
// Used as a guard so we never accidentally MOVE a non-spam message, so it
|
||||
// matches the leaf exactly rather than by substring: a user folder called
|
||||
// "Spam reports" holds mail its owner wants kept where it is.
|
||||
func IsSpamMailboxName(name string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(name))
|
||||
for _, candidate := range ImapSpam {
|
||||
if strings.EqualFold(name, candidate) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, strings.ToLower(candidate)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return matchesFolderName(strings.ToLower(leaf(strings.TrimSpace(name))), ImapSpam)
|
||||
}
|
||||
|
||||
// IsSpamMailbox returns true if the mailbox's attributes or name identify it
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
DefaultColor = "#c4c8cf"
|
||||
// LimitMin/LimitMax bound every per-mailbox and per-campaign daily send
|
||||
@@ -32,7 +34,11 @@ const (
|
||||
// HTML newsletters mid-document; 512 KB clears the overwhelming majority
|
||||
// of them while still bounding what one message can cost.
|
||||
MaxEmailBodySize = 512 * 1024 // 512 KB
|
||||
MaxEmailFolders = 30
|
||||
// MaxEmailFolders bounds how many folders one mailbox's sync follows.
|
||||
// INBOX and the special folders are always kept; past the cap the rest
|
||||
// are taken in the server's order and the overflow is relayed once as a
|
||||
// warning rather than failing the mailbox.
|
||||
MaxEmailFolders = 100
|
||||
|
||||
// MaxSearchBodyText bounds the plain-text copy of a message body kept in
|
||||
// Postgres for full-text search. The body itself lives in object storage;
|
||||
@@ -43,6 +49,21 @@ const (
|
||||
// memory, so a large folder is never buffered whole before any body is read.
|
||||
ImapFetchBatchSize = 200
|
||||
|
||||
// ImapCommandIdleTimeout is how long an IMAP command may wait for the
|
||||
// server to say anything before the session is declared dead and
|
||||
// re-dialed on the next pass. go-imap bounds the bytes of a response but
|
||||
// not the wait for its first byte, which is where a peer that vanished
|
||||
// without a FIN parks a command forever.
|
||||
ImapCommandIdleTimeout = 2 * time.Minute
|
||||
|
||||
// Servers without CONDSTORE (Outlook.com, Microsoft 365 over IMAP,
|
||||
// Yahoo, many hosted servers) cannot say which messages changed, so read
|
||||
// state and flags are mirrored by re-reading the flags of a folder's
|
||||
// newest window every ImapFlagScanInterval and diffing against the
|
||||
// previous scan. New mail still lands within one pass through UIDNEXT.
|
||||
ImapFlagScanInterval = 10 * time.Minute
|
||||
ImapFlagScanWindow = 5_000 // newest UIDs per folder the scan covers
|
||||
|
||||
// Mailbox sync fair use. Connecting a mailbox imports its recent history
|
||||
// (the backfill), then follows new mail (live). Every number below is a
|
||||
// default: the four Sync* settings are operator-editable in the admin
|
||||
|
||||
+24
-2
@@ -30,7 +30,12 @@ const (
|
||||
type MailErrorCode string
|
||||
|
||||
const (
|
||||
MailErrorCodeFolderLimit MailErrorCode = "MAX_FOLDERS_REACHED"
|
||||
MailErrorCodeFolderLimit MailErrorCode = "MAX_FOLDERS_REACHED"
|
||||
// MailErrorCodeFolderConflict is two folders on one mailbox reporting
|
||||
// the same UIDVALIDITY, the id a folder is stored under. Kept apart from
|
||||
// the folder limit because the way out is renaming the folder, not
|
||||
// deleting folders to get under a cap.
|
||||
MailErrorCodeFolderConflict MailErrorCode = "FOLDER_ID_CONFLICT"
|
||||
MailErrorCodeUpdateLimit MailErrorCode = "MAX_FOLDERS_REACHED"
|
||||
MailErrorCodeGoogleAuth MailErrorCode = "GOOGLE_AUTHENTICATION_FAILED"
|
||||
MailErrorCodeGooglePayment MailErrorCode = "GOOGLE_PAYMENT_REQUIRED"
|
||||
@@ -120,7 +125,18 @@ func MError(eType MailErrorType, code MailErrorCode, message string, resolveMeth
|
||||
}
|
||||
|
||||
var (
|
||||
ErrMailFoldersMax = MError(MailErrorCritical, MailErrorCodeFolderLimit, fmt.Sprintf("You reached the maximum limit of %d folders reached.", config.MaxEmailFolders), MailErrorResolveMethodReload)
|
||||
// ErrMailFoldersOverflow is relayed once per worker session when a
|
||||
// mailbox has more folders than the sync follows. The inbox and the
|
||||
// special folders are always among the ones kept.
|
||||
ErrMailFoldersOverflow = func(left int) *MailError {
|
||||
return MError(MailErrorWarning, MailErrorCodeFolderLimit, fmt.Sprintf("This mailbox has %d more folders than the %d Warmbly follows. The inbox, sent, drafts, spam and trash are always synced; the rest are taken in the server's order.", left, config.MaxEmailFolders), MailErrorResolveMethodNone)
|
||||
}
|
||||
// ErrMailFoldersConflict is relayed when the mail server gives two
|
||||
// folders the same UIDVALIDITY, which is the id everything downstream
|
||||
// identifies a folder by. Only one of them can be followed.
|
||||
ErrMailFoldersConflict = func(left int) *MailError {
|
||||
return MError(MailErrorWarning, MailErrorCodeFolderConflict, fmt.Sprintf("%d folder(s) on this mailbox share an internal id with another folder, so only one of each pair is synced.", left), MailErrorResolveMethodNone)
|
||||
}
|
||||
ErrMailUpdateLimit = MError(MailErrorCritical, MailErrorCodeUpdateLimit, "Your inbox has received an unusually large number of updates. Please reactivate your inbox once the issue is resolved.", MailErrorResolveMethodReload)
|
||||
ErrMailGoogleAuth = MError(MailErrorCritical, MailErrorCodeGoogleAuth, "Cannot access your Gmail account. Please re-authorize your account to restore mailbox access.", MailErrorResolveMethodReload)
|
||||
ErrMailGooglePayment = MError(MailErrorCritical, MailErrorCodeGooglePayment, "Gmail access blocked due to unpaid invoices. Please resolve the payment with Google.", MailErrorResolveMethodReload)
|
||||
@@ -235,6 +251,12 @@ func (e *MailError) GetUserErrorInfo() UserErrorInfo {
|
||||
case MailErrorCodeServerUnreachable:
|
||||
info.Title = "Connection Error"
|
||||
info.ActionRequired = "The email server is temporarily unavailable. We'll retry automatically."
|
||||
case MailErrorCodeFolderLimit:
|
||||
info.Title = "Some folders are not synced"
|
||||
info.ActionRequired = "Move or delete folders you no longer need if one you rely on is missing from the unibox."
|
||||
case MailErrorCodeFolderConflict:
|
||||
info.Title = "Two folders share an internal id"
|
||||
info.ActionRequired = "Your mail server gave two folders the same id, so only one of them is synced. Renaming the folder that is missing from the unibox, or recreating it, usually gives it a new one."
|
||||
case MailErrorCodeNotFound:
|
||||
info.Title = "Mailbox Item Missing"
|
||||
info.ActionRequired = "The folder or message is no longer on the mail server. Nothing to do; we'll skip it."
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE unibox_mailboxes DROP COLUMN IF EXISTS uid_next;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- UIDNEXT per folder: the incremental cursor on IMAP servers without
|
||||
-- CONDSTORE, where highestmodseq stays 0.
|
||||
ALTER TABLE unibox_mailboxes ADD COLUMN IF NOT EXISTS uid_next bigint NOT NULL DEFAULT 0;
|
||||
@@ -7,6 +7,13 @@ type Mailbox struct {
|
||||
Attrs []string `json:"attributes"`
|
||||
UIDValidity uint32 `json:"uid_validity"`
|
||||
HighestModSeq uint64 `json:"highestmodseq"`
|
||||
// UIDNext is the folder's next UID as last seen. It is the incremental
|
||||
// cursor on a server without CONDSTORE, where HighestModSeq stays 0.
|
||||
UIDNext uint32 `json:"uid_next"`
|
||||
// Delim is the hierarchy delimiter this server reported for the folder
|
||||
// ("/" on Gmail, "." on many Dovecots). Empty when the server reported
|
||||
// none, where the leaf is guessed instead.
|
||||
Delim string `json:"delim,omitempty"`
|
||||
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -29,32 +29,33 @@ func (r *mailboxRepository) CreateEntry(ctx context.Context, userId, emailId uui
|
||||
mb.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
INSERT INTO unibox_mailboxes (email_id, uid_validity, mailbox, attributes, highestmodseq, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO unibox_mailboxes (email_id, uid_validity, mailbox, attributes, highestmodseq, uid_next, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (email_id, uid_validity) DO UPDATE SET
|
||||
mailbox = EXCLUDED.mailbox,
|
||||
attributes = EXCLUDED.attributes,
|
||||
highestmodseq = EXCLUDED.highestmodseq,
|
||||
uid_next = EXCLUDED.uid_next,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`
|
||||
|
||||
// attributes is NOT NULL; a nil slice binds as SQL NULL. See textArray.
|
||||
_, err := r.db.Exec(ctx, query,
|
||||
emailId, mb.UIDValidity, mb.Name, textArray(mb.Attrs), mb.HighestModSeq, mb.UpdatedAt,
|
||||
emailId, mb.UIDValidity, mb.Name, textArray(mb.Attrs), mb.HighestModSeq, mb.UIDNext, mb.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *mailboxRepository) GetMailbox(ctx context.Context, userId, emailId uuid.UUID, uidValidity uint32) (*models.Mailbox, error) {
|
||||
query := `
|
||||
SELECT mailbox, attributes, uid_validity, highestmodseq, updated_at
|
||||
SELECT mailbox, attributes, uid_validity, highestmodseq, uid_next, updated_at
|
||||
FROM unibox_mailboxes
|
||||
WHERE email_id = $1 AND uid_validity = $2
|
||||
`
|
||||
|
||||
var mb models.Mailbox
|
||||
err := r.db.QueryRow(ctx, query, emailId, uidValidity).Scan(
|
||||
&mb.Name, &mb.Attrs, &mb.UIDValidity, &mb.HighestModSeq, &mb.UpdatedAt,
|
||||
&mb.Name, &mb.Attrs, &mb.UIDValidity, &mb.HighestModSeq, &mb.UIDNext, &mb.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
@@ -68,7 +69,7 @@ func (r *mailboxRepository) GetMailbox(ctx context.Context, userId, emailId uuid
|
||||
|
||||
func (r *mailboxRepository) ListMailboxes(ctx context.Context, userId, emailId uuid.UUID) ([]models.Mailbox, error) {
|
||||
query := `
|
||||
SELECT mailbox, attributes, uid_validity, highestmodseq, updated_at
|
||||
SELECT mailbox, attributes, uid_validity, highestmodseq, uid_next, updated_at
|
||||
FROM unibox_mailboxes
|
||||
WHERE email_id = $1
|
||||
`
|
||||
@@ -82,7 +83,7 @@ func (r *mailboxRepository) ListMailboxes(ctx context.Context, userId, emailId u
|
||||
var mailboxes []models.Mailbox
|
||||
for rows.Next() {
|
||||
var mb models.Mailbox
|
||||
if err := rows.Scan(&mb.Name, &mb.Attrs, &mb.UIDValidity, &mb.HighestModSeq, &mb.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&mb.Name, &mb.Attrs, &mb.UIDValidity, &mb.HighestModSeq, &mb.UIDNext, &mb.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailboxes = append(mailboxes, mb)
|
||||
|
||||
Reference in New Issue
Block a user