bottleneckcrm/mobile/lib/data/models/mailbox.dart
Ashwin ed4263a534 Refactor tests and add new functionality for ticket closing and mailbox handling
- Updated phone viewport test to check for new text related to SNS topics.
- Enhanced audit log screen tests with new credential events and filters.
- Modified mailbox form sheet tests to handle SNS Topic ARN correctly.
- Introduced new tests for ticket form close date functionality, ensuring correct behavior for optional close dates.
- Added support for organization timezone handling in tests, ensuring accurate date calculations based on user organization.
- Improved unit tests for mailbox and organization date handling, ensuring proper behavior for various scenarios.
- Implemented bulk close date functionality in ticket selection tests, verifying correct behavior for date selection and submission.
2026-09-27 11:43:51 +05:30

324 lines
13 KiB
Dart

/// Inbound email addresses, the ones that turn mail into tickets.
///
/// **No secrets here.** `InboundMailbox` carries a `webhook_secret` column,
/// write-only server-side, so it never arrives and this model has no field for
/// it. The `topic_arn` pin is not a secret but embeds the AWS account id, so the
/// server sends it to admins only; [Mailbox.topicArn] holds it for the admin's
/// form. Every viewer gets [Mailbox.hasTopicArn], the boolean that says whether
/// the address is connected.
///
/// `frontend/src/routes/(app)/settings/inbound-email/delivery.js` carries the
/// same rules.
library;
import 'lookup_models.dart';
/// What an address actually does with mail, as opposed to what its switch says.
///
/// `isActive` is one of three gates, and the settings pages used to draw it as
/// the only one. Two states read as working and are silent:
///
/// - [unsupported]: `InboundMailboxWebhookView.post` answers 501 for every
/// provider except SES, and the model offers four.
/// - [unconfirmed]: an SES address with no SNS topic pin rejects every
/// message. It gets one when an admin enters the Topic ARN, or when a
/// verified SubscriptionConfirmation arrives from a topic in an AWS account
/// the server allows (`INBOUND_SNS_ACCOUNT_IDS`); until then it is here.
///
/// Ordered as the webhook checks them, so this answers "which gate does a
/// delivery fail first". None of it is a security control: every gate is
/// enforced in `cases/inbound_views.py`.
enum MailboxDelivery {
off('Creating nothing'),
unsupported('Provider not wired up'),
unconfirmed('Not connected yet'),
live('Creating tickets');
const MailboxDelivery(this.label);
final String label;
bool get isLive => this == MailboxDelivery.live;
}
/// The providers the webhook actually handles. Everything else 501s.
const List<String> supportedMailboxProviders = ['ses'];
/// `InboundMailbox.PROVIDER_CHOICES`, in the model's order.
const Map<String, String> mailboxProviderLabels = {
'ses': 'AWS SES',
'mailgun': 'Mailgun',
'postmark': 'Postmark',
'imap': 'IMAP',
};
/// The label a provider picker shows.
///
/// The three unimplemented providers stay selectable: the column accepts them,
/// an admin may be recording an address before the integration exists, and
/// hiding them would silently rewrite a mailbox already set to one.
String mailboxProviderLabel(String provider) {
final label = mailboxProviderLabels[provider] ?? provider;
return supportedMailboxProviders.contains(provider)
? label
: '$label (not wired up yet)';
}
class Mailbox {
const Mailbox({
required this.id,
this.address = '',
this.provider = 'ses',
this.isActive = true,
this.hasTopicArn = false,
this.topicArn,
this.defaultPriority = 'Normal',
this.defaultCaseType,
this.defaultAssignee,
this.casesLast30d = 0,
this.lastReceivedAt,
});
final String id;
final String address;
final String provider;
final bool isActive;
/// Whether the mailbox has its SNS topic pin. **Defaults to false**, which
/// reads as "not connected yet": failing closed is the safe direction for the
/// flag a green pill is drawn from.
final bool hasTopicArn;
/// The pin itself, which the server sends to admins only. Null for a member,
/// and for an admin whose mailbox has none (the server sends `""`).
final String? topicArn;
final String defaultPriority;
/// Null means the ticket opens with no type set, which is a real choice.
final String? defaultCaseType;
/// Whoever new tickets from this address land on before routing runs. Null is
/// "unassigned, then routed".
final UserLookup? defaultAssignee;
/// Server-computed from the `EmailMessage.mailbox` FK, so it is attributed
/// rather than guessed.
final int casesLast30d;
final DateTime? lastReceivedAt;
factory Mailbox.fromJson(Map<String, dynamic> json) {
final assignee = json['default_assignee'];
return Mailbox(
id: json['id']?.toString() ?? '',
address: json['address']?.toString() ?? '',
provider: json['provider']?.toString() ?? 'ses',
isActive: json['is_active'] as bool? ?? true,
hasTopicArn: json['has_topic_arn'] == true,
topicArn: json['topic_arn'] is String && json['topic_arn'] != ''
? json['topic_arn'] as String
: null,
defaultPriority: json['default_priority']?.toString() ?? 'Normal',
defaultCaseType:
(json['default_case_type'] as String?)?.trim().isEmpty ?? true
? null
: json['default_case_type'] as String,
defaultAssignee: assignee is Map
? UserLookup.fromJson(assignee.cast<String, dynamic>())
: null,
casesLast30d: (json['cases_last_30d'] as num?)?.round() ?? 0,
lastReceivedAt: json['last_received_at'] == null
? null
: DateTime.tryParse(json['last_received_at'].toString())?.toLocal(),
);
}
/// The first gate mail to this address would fail, or [MailboxDelivery.live].
MailboxDelivery get delivery {
if (!isActive) return MailboxDelivery.off;
if (!supportedMailboxProviders.contains(provider)) {
return MailboxDelivery.unsupported;
}
if (!hasTopicArn) return MailboxDelivery.unconfirmed;
return MailboxDelivery.live;
}
/// Why nothing is arriving, or null when something is.
String? get deliveryExplanation {
switch (delivery) {
case MailboxDelivery.off:
return 'Mail still arrives here and no ticket is opened. Nothing '
'bounces, so whoever wrote gets silence rather than an error.';
case MailboxDelivery.unsupported:
final label = mailboxProviderLabels[provider] ?? provider;
return '$label deliveries are not implemented. The webhook refuses '
'them, so mail to this address becomes nothing whatever else is '
'set here. Only AWS SES is wired up.';
case MailboxDelivery.unconfirmed:
return 'Waiting for its SNS topic. Every message is refused until '
'this address has a Topic ARN: an admin can enter it here, or it is '
'set when AWS confirms a subscription from an AWS account this '
'server allows.';
case MailboxDelivery.live:
return null;
}
}
/// What a ticket from here starts out as, in one line.
String get opensAs {
final parts = <String>[defaultPriority];
if (defaultCaseType != null) parts.add(defaultCaseType!);
parts.add(
defaultAssignee == null
? 'then routed'
: 'assigned to ${defaultAssignee!.displayName}',
);
return parts.join(' · ');
}
}
/// The addresses that would actually open a ticket right now.
///
/// Derived from the rows rather than from the server's `totals.active`, which
/// counts `is_active` alone. `GET /cases/mailboxes/` returns every mailbox in
/// the org unfiltered and unpaginated, so this counts the same set and answers
/// the question the header asks.
int deliveringMailboxCount(List<Mailbox> mailboxes) =>
mailboxes.where((m) => m.delivery.isLive).length;
/// The rows worth a banner: switched on, and creating nothing.
///
/// A turned-off address is left out on purpose. Somebody chose that, and it is
/// the one state of the three the row already read correctly.
List<Mailbox> silentMailboxes(List<Mailbox> mailboxes) => mailboxes
.where(
(m) =>
m.delivery == MailboxDelivery.unsupported ||
m.delivery == MailboxDelivery.unconfirmed,
)
.toList(growable: false);
/// Live addresses first, then alphabetically, which is how an admin scans.
List<Mailbox> sortedMailboxes(List<Mailbox> mailboxes) {
final out = [...mailboxes];
out.sort((a, b) {
if (a.delivery.isLive != b.delivery.isLive) {
return a.delivery.isLive ? -1 : 1;
}
return a.address.compareTo(b.address);
});
return out;
}
/// `cases.models.PRIORITY_CHOICE` and the mailbox's own case-type choices.
const List<String> mailboxPriorities = ['Low', 'Normal', 'High', 'Urgent'];
const List<String> mailboxCaseTypes = ['Question', 'Incident', 'Problem'];
/// The body for creating or editing a mailbox.
///
/// **No `webhook_secret`.** It would mean a credential travels to a phone and
/// back on every edit, and an empty field would blank the column, so it is not
/// a field on the form, which is the surest way not to send one.
///
/// `topicArn` is sent only when given: pass [mailboxTopicArnEdit]'s answer, so
/// a save sends the pin only when the admin changed it, and `''` clears it.
///
/// `isActive` is optional because the edit form does not own it: the row's own
/// Turn off / Turn on control does, and sending it from an edit would let a
/// stale form flip an address back on.
Map<String, dynamic> mailboxPayload({
required String address,
required String provider,
required String defaultPriority,
String? defaultCaseType,
String? defaultAssigneeId,
bool? isActive,
String? topicArn,
}) {
final body = <String, dynamic>{
'address': address.trim().toLowerCase(),
'provider': provider,
'default_priority': defaultPriority,
// Both are `allow_null=True`, and the empty string fails the ChoiceField
// and the PK lookup alike, so "none selected" has to travel as null.
'default_case_type': (defaultCaseType?.trim().isEmpty ?? true)
? null
: defaultCaseType,
'default_assignee_id': (defaultAssigneeId?.trim().isEmpty ?? true)
? null
: defaultAssigneeId,
};
if (isActive != null) body['is_active'] = isActive;
if (topicArn != null) body['topic_arn'] = topicArn.trim();
return body;
}
/// The `topic_arn` a form save should send, or null to leave it alone.
///
/// Only a change is sent. The form is prefilled with the pin as it stood when
/// the list loaded, and the webhook can pin a mailbox after that (a
/// confirmation from an allowed AWS account), so resending the stale value, or
/// the empty one, would silently undo a pin nobody on this form chose to undo.
/// Clearing the field on purpose is a change, and sends `''`.
///
/// `frontend/src/lib/server/v2/inbound-email.js` `topicArnEdit` is the same
/// rule.
String? mailboxTopicArnEdit(String submitted, String? loaded) {
final next = submitted.trim();
return next == (loaded ?? '').trim() ? null : next;
}
/// Turn an address on or off without rewriting it.
///
/// Its own body rather than a reuse of [mailboxPayload]: that builds a whole
/// mailbox, and a form that is not open has nothing to say about the address.
/// `InboundMailboxDetailView.put` is `partial=True`, so a single key leaves
/// everything else alone.
Map<String, dynamic> mailboxActivePayload(bool isActive) => {
'is_active': isActive,
};
/// What a create or edit has to fix before it is worth sending, or null.
///
/// `InboundMailboxSerializer` is the authority, including the duplicate-address
/// check, which needs the org and cannot happen here. This exists so an empty
/// field answers immediately rather than after a round trip.
String? mailboxAddressProblem(String address) {
final trimmed = address.trim();
if (trimmed.isEmpty) return 'Give the address.';
// Deliberately loose. `EmailField` is what decides, and a stricter pattern
// here would refuse addresses the server accepts.
final at = trimmed.indexOf('@');
if (at <= 0 || at == trimmed.length - 1 || trimmed.contains(' ')) {
return 'That does not look like an email address.';
}
return null;
}
/// What deleting an address does, said before it is done.
const String mailboxDeleteExplanation =
'Deleted for good. Mail sent here stops becoming tickets, and the topic '
'pin goes with it, so a delivery already in flight from AWS stops '
'verifying. Tickets already opened from this address are not touched.';
/// What turning one off does, which is not the same as deleting it.
const String mailboxDeactivateExplanation =
'Stops opening tickets. Mail keeps arriving and nothing bounces, so anyone '
'writing here gets no ticket and no error. It stays in the list, off, '
'until it is turned back on.';
/// The two checks a delivery has to clear, said once on the screen.
///
/// This replaced a card that claimed a per-address shared secret proved a
/// delivery genuine and that the server minted one on create. Neither is true:
/// nothing in the backend compares that column.
const String mailboxAuthExplanation =
'AWS signs each notification, and the address has to be pinned to the exact '
'SNS topic it was subscribed to. The signature alone proves only that some '
'AWS account sent it, so without the pin anyone who learned an address id '
'could have AWS sign forged mail into this organization. An admin can '
'enter the pin as the address\'s Topic ARN. Left blank, it is set by the '
'first subscription AWS confirms from an AWS account this server allows; '
'a subscription from any other account is refused. Only admins can see '
'the ARN, because it carries the AWS account id.';